Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c1d4f359e | ||
|
|
8d0e587114 | ||
|
|
b19d09a236 | ||
|
|
50b5feced5 | ||
|
|
9dcbac61ec | ||
|
|
957f9c9abd | ||
|
|
e9f90b42cb | ||
|
|
92d4ea3411 | ||
|
|
49021d9942 | ||
|
|
67e47cbed5 | ||
|
|
b00f78505d | ||
|
|
a32ab3b7ce | ||
|
|
e3d56d90af | ||
|
|
051614a919 | ||
|
|
bac691aae1 | ||
|
|
71428cdaad | ||
|
|
cf7ac8c51c | ||
|
|
002823ede3 | ||
|
|
90e2c8ef3d | ||
|
|
4b6ae923c6 | ||
|
|
4b64a113b5 | ||
|
|
06c90b4e86 | ||
|
|
300944697f | ||
|
|
7aa33a4789 | ||
|
|
a562909bc5 | ||
|
|
66459bfae4 | ||
|
|
1a727e6ff5 | ||
|
|
eeeecb0604 | ||
|
|
4e6dd2a029 | ||
|
|
cd3ef646d2 | ||
|
|
fa93e31ca1 | ||
|
|
6093879aa9 | ||
|
|
cebeca467f | ||
|
|
e50257fb34 | ||
|
|
6dce9f9317 | ||
|
|
539d5e4c33 | ||
|
|
5551d9e667 | ||
|
|
ef44d34944 | ||
|
|
edccfff7ff | ||
|
|
16212702cb | ||
|
|
6f191c6a69 | ||
|
|
d1cfab95c2 | ||
|
|
64ca13b3a1 | ||
|
|
f4adc99a01 | ||
|
|
5f4a212786 | ||
|
|
2a50704a7f | ||
|
|
994a27886e | ||
|
|
b46d8b665f | ||
|
|
d8d2e6ee46 | ||
|
|
176acf6f40 | ||
|
|
3fab68c118 | ||
|
|
0cd4025f3c | ||
|
|
5360caaddf | ||
|
|
4c30649598 | ||
|
|
c997afbfd4 | ||
|
|
7f6d8a4a08 |
@@ -0,0 +1,31 @@
|
||||
---
|
||||
name: "Relato de Bug"
|
||||
about: "Relate um problema ou comportamento inesperado no CyberCore Signer"
|
||||
title: "[BUG] "
|
||||
labels:
|
||||
- "bug"
|
||||
---
|
||||
|
||||
### Descrição do Problema
|
||||
Uma descrição clara e concisa do comportamento inesperado ocorrido.
|
||||
|
||||
### Passos para Reproduzir
|
||||
1. Iniciar o CyberCore Signer com o comando `...`
|
||||
2. Acessar a aplicação web ou endpoint `...`
|
||||
3. Tentar executar a operação de assinatura `...`
|
||||
4. Observar o erro reportado.
|
||||
|
||||
### Comportamento Esperado
|
||||
O que você esperava que acontecesse normalmente.
|
||||
|
||||
### Logs e Mensagens de Diagnóstico
|
||||
```text
|
||||
Cole aqui o trecho relevante de log do agente (sem expor dados sensíveis ou chaves).
|
||||
```
|
||||
|
||||
### Informações do Ambiente
|
||||
* **Sistema Operacional:** Windows 10 / Windows 11 / Ubuntu / Outro (especifique)
|
||||
* **Versão do Java:** 25 LTS (especifique a vendor: Temurin, Corretto, Oracle)
|
||||
* **Tipo de Certificado Utilizado:** A1 (arquivo `.pfx`) / A3 (Token USB / Smart Card / Windows-MY)
|
||||
* **Fabricante do Hardware A3 (se aplicável):** SafeSign / SafeNet / GD Burti / Outro
|
||||
* **Navegador e Versão (se aplicável ao SDK):** Chrome / Edge / Firefox
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
name: "Solicitação de Funcionalidade"
|
||||
about: "Sugira uma nova funcionalidade ou melhoria para o agente ou SDK"
|
||||
title: "[FEAT] "
|
||||
labels:
|
||||
- "enhancement"
|
||||
---
|
||||
|
||||
### Problema ou Necessidade
|
||||
Descreva o cenário de uso ou o desafio que motivou esta solicitação.
|
||||
|
||||
### Solução Proposta
|
||||
Uma descrição clara do recurso ou melhoria que você gostaria de ver implementado no CyberCore Signer.
|
||||
|
||||
### Módulos Afetados
|
||||
* [ ] `modules/cybercore-core` (regras criptográficas, provedores, protocolo CCSP)
|
||||
* [ ] `modules/cybercore-ui` (telas JavaFX, diálogos, selo visual, system tray)
|
||||
* [ ] `modules/cybercore-app` (ponto de entrada, empacotamento nativo)
|
||||
* [ ] `sdk/` (SDK Web JavaScript)
|
||||
* [ ] `docs/` (especificações e manuais)
|
||||
|
||||
### Alternativas Consideradas
|
||||
Descreva quaisquer alternativas ou soluções de contorno que você considerou.
|
||||
|
||||
### Contexto Adicional
|
||||
Adicione qualquer imagem, rascunho de tela ou especificação relevante.
|
||||
@@ -0,0 +1,21 @@
|
||||
### Descrição das Alterações
|
||||
Resuma de forma clara o objetivo deste Pull Request e as alterações realizadas.
|
||||
|
||||
### Tipo de Mudança
|
||||
* [ ] Correção de bug (`fix`)
|
||||
* [ ] Nova funcionalidade (`feat`)
|
||||
* [ ] Refatoração sem alteração de comportamento (`refactor`)
|
||||
* [ ] Atualização de documentação (`docs`)
|
||||
* [ ] Ajuste de testes ou métricas de cobertura (`test`)
|
||||
* [ ] Manutenção de infraestrutura ou CI (`ci` / `chore`)
|
||||
|
||||
### Checklist de Qualidade e Segurança
|
||||
* [ ] O código segue o padrão e regras de arquitetura do projeto.
|
||||
* [ ] Nenhuma credencial, PIN, senha ou chave privada real foi comitada.
|
||||
* [ ] A validação `./mvnw clean verify` foi executada e passou com 100% de sucesso.
|
||||
* [ ] A cobertura mínima do JaCoCo foi respeitada em todos os módulos afetados.
|
||||
* [ ] As mensagens de commit seguem a convenção *Conventional Commits*.
|
||||
* [ ] A documentação técnica foi atualizada (quando aplicável).
|
||||
|
||||
### Issue Relacionada
|
||||
Fecha #
|
||||
@@ -0,0 +1,112 @@
|
||||
name: CI Pipeline
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- 'feature/**'
|
||||
- 'fix/**'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-linux:
|
||||
name: Build & Verify (Linux x86_64)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repositório
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Configurar Java 25 LTS (Temurin)
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '25'
|
||||
cache: 'maven'
|
||||
|
||||
- name: Conceder permissão de execução ao Maven Wrapper
|
||||
run: chmod +x ./mvnw
|
||||
|
||||
- name: Instalar SoftHSM2 para testes de hardware PKCS#11
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y softhsm2
|
||||
sudo mkdir -p /var/lib/softhsm/tokens
|
||||
sudo chmod 777 /var/lib/softhsm/tokens
|
||||
|
||||
- name: Executar verificação Maven (Compilação, Testes, JaCoCo, SpotBugs, ArchUnit)
|
||||
run: ./mvnw -B clean verify
|
||||
|
||||
- name: Publicar Relatórios de Cobertura JaCoCo
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: jacoco-reports-linux
|
||||
path: |
|
||||
modules/*/target/site/jacoco/**
|
||||
modules/*/target/jacoco.exec
|
||||
retention-days: 14
|
||||
|
||||
- name: Publicar Relatórios de Análise Estática SpotBugs
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: spotbugs-reports-linux
|
||||
path: |
|
||||
modules/*/target/spotbugsXml.xml
|
||||
modules/*/target/site/spotbugs/**
|
||||
retention-days: 14
|
||||
|
||||
- name: Publicar CycloneDX SBOM (Software Bill of Materials)
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: cyclonedx-sbom
|
||||
path: |
|
||||
target/bom.json
|
||||
target/bom.xml
|
||||
retention-days: 30
|
||||
|
||||
build-windows:
|
||||
name: Build & Verify (Windows)
|
||||
# Runner Windows: requer act_runner nativo em Windows na instância Gitea.
|
||||
# Caso sua instância utilize apenas Docker Linux, configure labels: [windows] no host correspondente.
|
||||
runs-on: windows-latest
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- name: Checkout repositório
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Configurar Java 25 LTS (Temurin)
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '25'
|
||||
cache: 'maven'
|
||||
|
||||
- name: Executar verificação Maven no Windows
|
||||
run: .\mvnw.cmd -B clean verify
|
||||
shell: cmd
|
||||
|
||||
- name: Publicar Relatórios JaCoCo (Windows)
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: jacoco-reports-windows
|
||||
path: |
|
||||
modules/*/target/site/jacoco/**
|
||||
retention-days: 14
|
||||
|
||||
- name: Publicar Relatórios SpotBugs (Windows)
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: spotbugs-reports-windows
|
||||
path: |
|
||||
modules/*/target/spotbugsXml.xml
|
||||
retention-days: 14
|
||||
@@ -77,4 +77,8 @@ $RECYCLE.BIN/
|
||||
*~
|
||||
|
||||
# Arquivos temporários de Scratch locais
|
||||
scratch/temp/
|
||||
scratch/
|
||||
|
||||
# Agentes e caches locais
|
||||
.agents/**/cache/
|
||||
.agents/**/temp/
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# Changelog
|
||||
|
||||
Todas as mudanças relevantes deste projeto serão documentadas neste arquivo.
|
||||
|
||||
O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/),
|
||||
e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/).
|
||||
|
||||
---
|
||||
|
||||
## [Não publicado]
|
||||
|
||||
### Adicionado
|
||||
- Pipeline de integração contínua (CI) via Gitea Actions para Linux e Windows.
|
||||
- Verificação de cobertura mínima de testes via meta `jacoco:check` calibrada por módulo.
|
||||
- Configuração de atualização automatizada de dependências via Renovate com agrupamento específico.
|
||||
- Templates de issues e pull requests para Gitea em `.gitea/`.
|
||||
- Guia de contribuição `CONTRIBUTING.md` e especificação de release em `docs/release-and-versioning.md`.
|
||||
|
||||
### Modificado
|
||||
- Alinhamento da documentação e scripts de automação para exigir estritamente o JDK 25 LTS.
|
||||
- Refinamento do `.gitignore` para ignorar a pasta `scratch/` e mover script utilitário para `scripts/`.
|
||||
- Atualização do filtro de exclusão do SpotBugs com justificativas técnicas detalhadas para garantir verificação completa e limpa.
|
||||
|
||||
---
|
||||
|
||||
## [0.1.0] - 2026-10-08
|
||||
|
||||
### Adicionado
|
||||
- **Núcleo Criptográfico (`cybercore-core`):**
|
||||
- Gerenciador central de certificados digitais (`CertificateManager`).
|
||||
- Provedor para certificados em arquivo A1 PKCS#12 (`.pfx` / `.p12`).
|
||||
- Provedor para repositório do Windows (`Windows-MY` via SunMSCAPI / CNG).
|
||||
- Provedor para dispositivos criptográficos A3 PKCS#11 (tokens USB e smartcards).
|
||||
- Assinatura digital padrão PAdES (PDF) em conformidade com as normas ICP-Brasil (Apache PDFBox e Bouncy Castle).
|
||||
- Assinatura digital padrão XAdES-BES / XMLDSig para notas e documentos fiscais eletrônicos.
|
||||
- Suporte a carimbo de tempo TSA e validação de longo prazo (LTV).
|
||||
- Servidor HTTP local CCSP/1 (`127.0.0.1:21325`) com bind exclusivo em loopback, proteção anti-replay via nonce e controle de concorrência por fila.
|
||||
- **Interface Gráfica Nativa (`cybercore-ui`):**
|
||||
- Aplicação moderna em JavaFX 25 com suporte a tema Claro e Escuro (Dual-Theme).
|
||||
- Dashboard de status do agente e leitor de certificados em tempo real.
|
||||
- Diálogos de autorização prévia de aplicações web e confirmação visual de assinaturas.
|
||||
- Designer e visualizador de selos visuais em PDF com renderização SVG e Java2D.
|
||||
- Integração com a bandeja do sistema operacional (System Tray).
|
||||
- **Módulo Executável (`cybercore-app`):**
|
||||
- Ponto de entrada consolidado (`CyberCoreSignerApplication`), rotinas de telemetria diagnóstica e testes de arquitetura com ArchUnit.
|
||||
- Empacotamento de Fat JAR autocontido via Maven Shade Plugin.
|
||||
- **SDK JavaScript (`@cybercore/signer-sdk`):**
|
||||
- Cliente web com métodos `connect()`, `isInstalled()`, `getCertificates()`, `sign()`, `verify()`.
|
||||
- Página de demonstração interativa para testes de integração web.
|
||||
- **Documentação Técnica:**
|
||||
- Especificação do protocolo CCSP/1 (`docs/CCSP.md`).
|
||||
- Diretrizes de segurança, arquitetura, diagnóstico e manuais de instalação.
|
||||
|
||||
### Segurança
|
||||
- Isolamento estrito de chaves privadas e senhas na máquina do usuário local (nunca trafegadas pela rede nem registradas em logs).
|
||||
- Destruição segura de senhas em memória após uso via `Arrays.fill(..., '\0')`.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Guia de Contribuição — CyberCore Signer
|
||||
|
||||
Agradecemos o interesse em contribuir com o **CyberCore Signer**! Para garantir a segurança criptográfica, a estabilidade e a manutenibilidade do projeto, siga as orientações descritas abaixo.
|
||||
|
||||
---
|
||||
|
||||
## 1. Pré-requisitos do Ambiente
|
||||
|
||||
* **Java Development Kit (JDK):** Versão 25 LTS (Eclipse Temurin, OpenJDK ou Amazon Corretto).
|
||||
* **Maven Wrapper:** Utilize sempre o executável `./mvnw` (Linux/macOS) ou `mvnw.cmd` (Windows) embutido no projeto.
|
||||
* **Git:** Com suporte a quebra de linha `LF`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Princípios de Segurança Invioláveis
|
||||
|
||||
Como este é um software de assinatura digital e custódia de credenciais criptográficas:
|
||||
|
||||
1. **Nunca exponha credenciais:** É estritamente proibido registrar, logar ou comitar PINs, senhas de arquivos A1, chaves privadas ou certificados de produção.
|
||||
2. **Testes isolados:** Use fixtures mockadas ou certificados autoassinados temporários gerados dinamicamente nos testes.
|
||||
3. **Loopback estrito:** O servidor CCSP escuta exclusivamente em `127.0.0.1:21325`. Nunca altere o bind para interfaces externas.
|
||||
|
||||
---
|
||||
|
||||
## 3. Padrão de Commits
|
||||
|
||||
Adotamos a especificação [Conventional Commits](https://www.conventionalcommits.org/pt-br/v1.0.0/). Cada commit deve representar uma alteração coesa e atômica:
|
||||
|
||||
* `feat:` Nova funcionalidade no agente, SDK ou UI.
|
||||
* `fix:` Correção de bug sem alteração de escopo.
|
||||
* `docs:` Alterações exclusivas na documentação.
|
||||
* `test:` Adição ou melhoria de suítes de testes.
|
||||
* `refactor:` Alterações de código que não mudam o comportamento funcional.
|
||||
* `chore:` Tarefas de manutenção de build, scripts ou dependências.
|
||||
* `ci:` Alterações no pipeline de CI/CD.
|
||||
|
||||
Exemplo: `feat(core): add support for custom pkcs11 pin retry limit`
|
||||
|
||||
---
|
||||
|
||||
## 4. Validação Pré-Commit / Pré-PR
|
||||
|
||||
Antes de submeter um Pull Request, certifique-se de que o build completo e todas as análises de qualidade passam localmente:
|
||||
|
||||
```bash
|
||||
# Executa compilação, testes, checagem JaCoCo, SpotBugs e ArchUnit:
|
||||
./mvnw clean verify
|
||||
```
|
||||
|
||||
Critérios obrigatórios:
|
||||
* Todos os testes unitários e de integração devem passar com sucesso.
|
||||
* A cobertura mínima definida no `jacoco:check` de cada módulo deve ser mantida ou superada.
|
||||
* Nenhum novo apontamento de segurança ou erro do SpotBugs deve ser introduzido.
|
||||
|
||||
---
|
||||
|
||||
## 5. Fluxo de Pull Requests
|
||||
|
||||
1. Crie uma branch descritiva a partir de `main` (ex: `feature/suporte-token-safenet` ou `fix/pades-timestamp-timeout`).
|
||||
2. Mantenha os commits organizados e pequenos.
|
||||
3. Abra o Pull Request preenchendo o template oficial do repositório em `.gitea/pull_request_template.md`.
|
||||
4. Garanta que o pipeline de CI do Gitea Actions finalize com status verde.
|
||||
@@ -1,100 +1,137 @@
|
||||
# CyberCore Signer
|
||||
|
||||
> **Agente Desktop de Assinatura Digital e Autenticação Criptográfica (A1 / A3 / PKCS#11) para Windows e Linux.**
|
||||
> **Agente Desktop Nativo 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.
|
||||
[](https://openjdk.org/)
|
||||
[](https://openjfx.io/)
|
||||
[](docs/CCSP.md)
|
||||
[](https://www.gov.br/iti/pt-br)
|
||||
[](#licença)
|
||||
|
||||
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.
|
||||
O **CyberCore Signer** é uma solução desktop desenvolvida em Java 25 LTS com interface gráfica moderna JavaFX e segurança criptográfica estrita JCA/JCE. Permite que aplicações web autorizadas solicitem assinaturas digitais, autenticação de sessão e validação de documentos utilizando certificados digitais padrão ICP-Brasil (A1 em arquivo e A3 em tokens/smartcards físicos) conectados ou instalados na máquina do usuário.
|
||||
|
||||
A comunicação local entre a página web e o agente utiliza o protocolo **CCSP (CyberCore Signing Protocol)** via HTTP localhost (`127.0.0.1:21325`), garantindo isolamento total de rede, consentimento visual explícito e preservação integral das chaves privadas na máquina local.
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Quick Start
|
||||
|
||||
### 🌐 Para Aplicações Web (via SDK JavaScript)
|
||||
|
||||
Instale e utilize o SDK oficial `@cybercore/signer-sdk` para conectar sua aplicação web ao agente local em poucos passos:
|
||||
|
||||
```javascript
|
||||
import { CyberCoreSigner } from '@cybercore/signer-sdk';
|
||||
|
||||
// 1. Inicializar o cliente do assinador
|
||||
const signer = new CyberCoreSigner({
|
||||
applicationName: 'Portal Meu Negócio'
|
||||
});
|
||||
|
||||
// 2. Obter certificados disponíveis na máquina do usuário
|
||||
const certificates = await signer.getCertificates();
|
||||
const cert = certificates[0]; // Seleciona o primeiro certificado disponível
|
||||
|
||||
// 3. Assinar o hash SHA-256 de um documento (a chave nunca sai da máquina!)
|
||||
const result = await signer.sign({
|
||||
certificateId: cert.id,
|
||||
digest: '47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=',
|
||||
documentName: 'contrato_prestacao_servicos.pdf'
|
||||
});
|
||||
|
||||
console.log('Assinatura gerada:', result.signature);
|
||||
console.log('Certificado titular:', result.subject_name);
|
||||
```
|
||||
|
||||
> Para o manual completo com exemplos de login digital, assinatura de PDF e XML, consulte o [Guia do SDK](docs/sdk.md).
|
||||
|
||||
---
|
||||
|
||||
### ☕ Para Desenvolvedores Desktop
|
||||
|
||||
#### Requisitos
|
||||
* **Java Development Kit (JDK):** Versão 25 LTS (OpenJDK, Temurin ou Corretto).
|
||||
* **Maven:** 3.9+ (ou utilizar o `./mvnw` embutido).
|
||||
* **Sistema Operacional:** Windows 10/11 ou Linux x86_64.
|
||||
|
||||
#### Compilação e Execução
|
||||
```bash
|
||||
# Compilar e gerar o JAR consolidado (Fat JAR sombreado):
|
||||
./mvnw clean package -DskipTests
|
||||
|
||||
# Executar o agente no Linux / macOS:
|
||||
./run.sh
|
||||
|
||||
# Executar o agente no Windows:
|
||||
run.bat
|
||||
|
||||
# Executar com logs detalhados de diagnóstico:
|
||||
java -jar modules/cybercore-app/target/cybercore-signer.jar --dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🏛 Arquitetura do Projeto
|
||||
|
||||
O projeto é estruturado em módulos Maven:
|
||||
O projeto é estruturado em módulos Maven desacoplados, garantindo isolamento entre regras criptográficas de baixo nível e a camada de interface gráfica:
|
||||
|
||||
* **[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.
|
||||
* **[`modules/cybercore-core`](modules/cybercore-core):** Núcleo de regras criptográficas (JCA/JCE, SunPKCS11, X.509, PAdES com Apache PDFBox e Bouncy Castle, XAdES-BES/XMLDSig, carimbo de tempo TSA, LTV, fila FIFO de concorrência e validação de cadeias). **Agnóstico de UI**, sem nenhuma dependência de JavaFX.
|
||||
* **[`modules/cybercore-ui`](modules/cybercore-ui):** Interface gráfica com o usuário construída em JavaFX 25, apresentando design moderno com alternância dinâmica Claro/Escuro (Dual-Theme), Dashboard, Gerenciador de Certificados, Designer de Selos Visuais SVG com Live Preview, Diálogos 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, diagnóstico pré-inicialização e servidor CCSP local.
|
||||
* **[`sdk/`](sdk):** SDK JavaScript oficial (`@cybercore/signer-sdk`) para integração simplificada de aplicações web modernas com o agente desktop.
|
||||
* **[`docs/`](docs):** Documentação técnica abrangente contendo especificações do protocolo, arquitetura, segurança e manuais de integração.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Requisitos
|
||||
## 📊 Matriz de Recursos e Conformidade
|
||||
|
||||
* **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).
|
||||
| Recurso | Padrão / Tecnologia | Descrição |
|
||||
|---|---|---|
|
||||
| **Certificados A1** | PKCS#12 / `.pfx` / `.p12` | Leitura em memória volátil com destruição segura de senhas |
|
||||
| **Certificados A3** | Windows-MY (CryptoAPI/CNG) & PKCS#11 | Acesso nativo a tokens USB e smartcards (SafeSign, SafeNet, GD Burti, OpenSC) |
|
||||
| **Assinatura PDF** | PAdES (ISO 32000-1 / ETSI TS 102 778) | Envelope CMS/PKCS#7, suporte a salvamento incremental e carimbo TSA |
|
||||
| **Selos Visuais PDF** | Renderização Java2D de Alta Resolução (300 DPI) | Brasões vetoriais, upload de logos, controle de sigilo de CPF (LGPD) e pré-visualização interativa |
|
||||
| **Assinatura XML** | XAdES-BES (ETSI TS 101 903 / DOC-ICP-15.03) | Suporte completo a NF-e, CT-e, MDF-e, eSocial e documentos envelopados |
|
||||
| **Assinatura Genérica** | CMS / CAdES / P7S / P7M | Modalidades atachada (enveloped) e destacada (detached) |
|
||||
| **Login Digital** | Challenge-Response ICP-Brasil | Autenticação web segura sem senhas tradicionais |
|
||||
| **Validação** | Verificação Criptográfica Local | Auditoria de integridade de hashes, validade de certificados e signatários |
|
||||
| **Interface Desktop** | JavaFX 25 com Dual-Theme | Modo Claro e Modo Escuro com comutação instantânea e atalhos na bandeja |
|
||||
| **Protocolo Local** | CCSP/1 REST (`127.0.0.1:21325`) | Bind estrito em loopback, proteção anti-replay via nonce e autorização de origens |
|
||||
|
||||
---
|
||||
|
||||
## 🛠 Compilação e Empacotamento
|
||||
## 🔒 Princípios Fundamentais de Segurança
|
||||
|
||||
### 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
|
||||
```
|
||||
1. **A Chave Privada Permanece na Estação de Trabalho:** A chave privada e os segredos (PIN do token A3 ou senha do arquivo A1) nunca saem da máquina do usuário nem trafegam pela rede ou saem em logs.
|
||||
2. **Assinatura por Digest Descentralizada:** Por padrão, a aplicação web calcula o hash SHA-256 do documento localmente e o CyberCore assina apenas o digest de 32 bytes, eliminando o tráfego desnecessário de arquivos inteiros.
|
||||
3. **Consentimento Visual Explícito:** Nenhuma operação de assinatura ocorre silenciosamente. Toda requisição exige autorização física do usuário em diálogo nativo do sistema operacional.
|
||||
4. **Isolamento de Origem e Anti-Replay:** Cada requisição web deve apresentar identificador de aplicação aprovado, nonce único com validação temporal (`expires_at`) e cabeçalho de origem verificado.
|
||||
5. **Fila FIFO de Operações:** O acesso a tokens físicos A3 é protegido por trava de concorrência (`signLock`) serializada, prevenindo travamento ou corrupção de sessão nos chips criptográficos.
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Princípios de Segurança e Privacidade
|
||||
## 📚 Documentação Técnica Detalhada
|
||||
|
||||
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.
|
||||
Para detalhes completos sobre arquitetura, protocolos e manuais técnicos, acesse o [Portal de Documentação](docs/README.md) ou consulte diretamente os links abaixo:
|
||||
|
||||
* 🌐 **[Protocolo CCSP/1](docs/CCSP.md):** Contrato REST, endpoints, códigos de status e anti-replay.
|
||||
* 📦 **[SDK Web JavaScript](docs/sdk.md):** Guia de integração e métodos da biblioteca oficial.
|
||||
* 🏛 **[Arquitetura do Sistema](docs/architecture.md):** Módulos, threading e fluxo sequencial.
|
||||
* 💳 **[Certificados A1, A3 e PKCS#11](docs/a1-a3-certificates.md):** Suporte a hardware, drivers e OIDs ICP-Brasil.
|
||||
* 📄 **[Assinaturas PDF e Selos SVG](docs/pades.md):** PAdES, renderização Java2D e selos visuais.
|
||||
* 📑 **[Assinaturas XML (XAdES-BES)](docs/xml-xmldsig.md):** Padrão DOC-ICP-15.03 e notas fiscais.
|
||||
* 🔑 **[Autenticação e Login Digital](docs/authentication-login.md):** Fluxo Challenge-Response via ICP-Brasil.
|
||||
* ⚠️ **[Catálogo Estruturado de Erros](docs/errors.md):** Tabela de erros `CcspErrorCode` e códigos HTTP.
|
||||
* 🛡️ **[Modelo de Segurança e Privacidade](docs/security.md):** Diretrizes de isolamento, memória e LGPD.
|
||||
* 🩺 **[Diagnóstico e Troubleshooting](docs/diagnostics.md):** Auto-teste e guia de resolução de problemas.
|
||||
* 💿 **[Instalação e Empacotamento Nativo](docs/installation.md):** Distribuição via `jpackage` sem JRE pré-instalado.
|
||||
* ⚖️ **[Limitações Técnicas](docs/limitations.md):** Declaração de dependências conforme a Regra 23.
|
||||
* 🛠️ **[Modo de Desenvolvimento](docs/development-mode.md):** Parâmetro `--dev` e certificados de teste.
|
||||
* 🎨 **[Layout Dual-Theme e Assinatura em Lote](docs/serpro-layout-and-batch.md):** Design da UI e operações em lote.
|
||||
|
||||
---
|
||||
|
||||
## 📚 Documentação Técnica
|
||||
## 📄 Licença e Propriedade
|
||||
|
||||
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.
|
||||
Copyright © 2026 **CyberCore Systems**. Todos os direitos reservados.
|
||||
Este software é proprietário e confidencial. O uso, distribuição ou modificação não autorizados são estritamente proibidos.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Pasta de Ícones (SVG) e Imagens (PNG) — CyberCore Signer
|
||||
# Recursos Visuais (Ícones e Imagens em SVG) — 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**.
|
||||
Esta pasta é o repositório oficial onde você pode visualizar, customizar e substituir livremente todos os **ícones em SVG** e as **imagens em SVG** do **CyberCore Signer**.
|
||||
|
||||
---
|
||||
|
||||
@@ -31,13 +31,23 @@ assets/
|
||||
│ ├── 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
|
||||
│ ├── star.svg # Ícone de Estrela notarial / favorita
|
||||
│ ├── close.svg # Ícone Fechar / Cancelar (cruz geométrica moderna)
|
||||
│ ├── arrow-right.svg # Ícone Avançar / Seta direcional
|
||||
│ ├── zoom-in.svg # Ícone Zoom In (+)
|
||||
│ ├── zoom-out.svg # Ícone Zoom Out (-)
|
||||
│ ├── zoom-fit.svg # Ícone Ajustar à Tela
|
||||
│ └── refresh.svg # Ícone Atualizar / Recarregar listas e status
|
||||
│
|
||||
└── 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)
|
||||
└── images/ # Imagens vetoriais SVG editáveis (com cópias PNG de fallback)
|
||||
├── app-icon.svg # Ícone oficial do app em SVG (Cristal/Diamante 3D azul)
|
||||
├── app-icon.png # Fallback em PNG (256x256 px)
|
||||
├── app-logo.svg # Logotipo horizontal oficial em SVG (CyberCore SIGNER)
|
||||
├── app-logo.png # Fallback em PNG (480x120 px)
|
||||
├── brand-banner.svg # Banner de cabeçalho e marca em SVG (600x160 px)
|
||||
├── brand-banner.png # Fallback em PNG (600x160 px)
|
||||
├── default-seal.svg # Selo de assinatura padrão ICP-Brasil em SVG (640x164 px)
|
||||
└── default-seal.png # Fallback em PNG (640x164 px)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -45,20 +55,22 @@ assets/
|
||||
## 🎨 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.
|
||||
- Todos estão no formato `.svg` padrão da W3C.
|
||||
- Consulte o guia completo de especificações em [docs/svg-design-guide.md](../docs/svg-design-guide.md) para tokens de cores, dimensões e boas práticas ao exportar do **Inkscape/Figma**.
|
||||
- Podem ser abertos e editados diretamente em editores de texto/código (VS Code) ou ferramentas vetoriais como **Inkscape, Figma, Adobe Illustrator**.
|
||||
- Se preferir usar um arquivo bitmap para algum ícone específico, basta salvar um `.png` correspondente na pasta (ex: `pdf.png`). O sistema detecta e renderiza automaticamente.
|
||||
|
||||
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.
|
||||
- Tanto o logotipo horizontal (`app-logo.svg`), o ícone do sistema (`app-icon.svg`), o banner institucional (`brand-banner.svg`) e o selo notarial padrão (`default-seal.svg`) agora são arquivos **SVG vetoriais editáveis**.
|
||||
- Você pode alterar cores, tipografias, brasões e dimensões diretamente nos arquivos SVG.
|
||||
- O `AssetManager` renderiza os arquivos SVG preservando fidelidade geométrica e suportando gradientes e alinhamentos.
|
||||
|
||||
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.
|
||||
3. **Ordem de Resolução e Carregamento:**
|
||||
Ao requisitar qualquer elemento gráfico, a aplicação segue a ordem de prioridade:
|
||||
1. **Pasta Física Local (`./assets/icons/` e `./assets/images/`)**: Arquivos `.svg` editados aqui têm prioridade máxima e entram em vigor imediatamente sem necessidade de recompilar.
|
||||
2. **Pasta do Usuário (`~/.cybercore/assets/`)**: Para customizações no diretório pessoal do usuário.
|
||||
3. **Recursos Empacotados no JAR (`/assets/`)**: SVGs e imagens internas empacotadas no pacote de distribuição.
|
||||
4. **Fallback Vetorial Nativo (`SvgIcons`)**: Garantia de que a interface nunca fique desprovida de grafismos caso algum arquivo seja removido.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 16 16"><title>info-circle-fill</title><path fill="currentColor" d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.93-9.412l-1 4.705c-.07.34.029.533.304.533c.194 0 .487-.07.686-.246l-.088.416c-.287.346-.92.598-1.465.598c-.703 0-1.002-.422-.808-1.319l.738-3.468c.064-.293.006-.399-.287-.47l-.451-.081l.082-.381l2.29-.287zM8 5.5a1 1 0 1 1 0-2a1 1 0 0 1 0 2"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
|
||||
<!-- Círculo azul e letra 'i' informativa centrada em (24, 24) -->
|
||||
<circle cx="24" cy="24" r="18" fill="#0288d1"/>
|
||||
<circle cx="24" cy="15" r="2.5" fill="#ffffff"/>
|
||||
<line x1="24" y1="21" x2="24" y2="33" stroke="#ffffff" stroke-width="4.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 429 B After Width: | Height: | Size: 362 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<path d="M 8 5 L 15 12 L 8 19" fill="none" stroke="#ffffff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 221 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<path d="M 6 6 L 18 18 M 18 6 L 6 18" fill="none" stroke="#64748b" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 228 B |
@@ -1,6 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
|
||||
<!-- Lua crescente ciano/índigo -->
|
||||
<path d="M 40 26.5 A 18 18 0 1 1 21.5 8 A 14 14 0 0 0 40 26.5 Z" fill="#38bdf8"/>
|
||||
<circle cx="36" cy="12" r="1.5" fill="#818cf8"/>
|
||||
<circle cx="42" cy="18" r="1" fill="#818cf8"/>
|
||||
<!-- Lua crescente ciano/índigo perfeitamente centrada em (24, 24) -->
|
||||
<path d="M 24 8 A 16 16 0 1 0 40 24 A 14 14 0 0 1 24 8 Z" fill="#38bdf8"/>
|
||||
<circle cx="34" cy="14" r="1.8" fill="#818cf8"/>
|
||||
<circle cx="38" cy="22" r="1.2" fill="#818cf8"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 314 B After Width: | Height: | Size: 344 B |
@@ -4,10 +4,10 @@
|
||||
<!-- Dobra de canto -->
|
||||
<path d="M 32 4 L 32 14 L 42 14 Z" fill="#ffcdd2" stroke="#d32f2f" stroke-width="2" stroke-linejoin="round"/>
|
||||
<!-- Linhas de texto representativas -->
|
||||
<line x1="14" y1="20" x2="28" y2="20" stroke="#b0bec5" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="14" y1="25" x2="36" y2="25" stroke="#b0bec5" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="14" y1="30" x2="36" y2="30" stroke="#b0bec5" stroke-width="2" stroke-linecap="round"/>
|
||||
<!-- Etiqueta vermelha com sigla PDF -->
|
||||
<rect x="12" y="33" width="24" height="11" rx="3" fill="#e53935"/>
|
||||
<text x="24" y="41.5" font-family="Segoe UI, Arial, sans-serif" font-size="8.5" font-weight="bold" fill="#ffffff" text-anchor="middle">PDF</text>
|
||||
<line x1="13" y1="18" x2="27" y2="18" stroke="#b0bec5" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="13" y1="22" x2="35" y2="22" stroke="#b0bec5" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="13" y1="26" x2="35" y2="26" stroke="#b0bec5" stroke-width="2" stroke-linecap="round"/>
|
||||
<!-- Etiqueta vermelha com sigla PDF perfeitamente centralizada -->
|
||||
<rect x="11" y="31" width="28" height="11" rx="3" fill="#d32f2f"/>
|
||||
<text x="25" y="36.5" font-family="'Segoe UI', Arial, sans-serif" font-size="8.5px" font-weight="bold" fill="#ffffff" text-anchor="middle" dominant-baseline="central">PDF</text>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 996 B After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<path d="M 20 4 L 20 9 L 15 9" fill="none" stroke="#0284c7" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M 4 20 L 4 15 L 9 15" fill="none" stroke="#0284c7" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M 5.8 9 A 7 7 0 0 1 18.5 7.5 L 20 9 M 4 15 L 5.5 16.5 A 7 7 0 0 0 18.2 15" fill="none" stroke="#0284c7" stroke-width="2.2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 510 B |
@@ -1,8 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
|
||||
<!-- Engrenagem simétrica de 8 dentes centrada em (24, 24) -->
|
||||
<path d="M 21 4 L 27 4 L 28 10 C 30 10.8 31.8 11.8 33.4 13.2 L 39 10 L 43 14 L 39.8 19.6 C 41.2 21.2 42.2 23 43 25 L 49 26 L 49 32 L 43 33 C 42.2 35 41.2 36.8 39.8 38.4 L 43 44 L 39 48 L 33.4 44.8 C 31.8 46.2 30 47.2 28 48 L 27 54 L 21 54 L 20 48 C 18 47.2 16.2 46.2 14.6 44.8 L 9 48 L 5 44 L 8.2 38.4 C 6.8 36.8 5.8 35 5 33 L -1 32 L -1 26 L 5 25 C 5.8 23 6.8 21.2 8.2 19.6 L 5 14 L 9 10 L 14.6 13.2 C 16.2 11.8 18 10.8 20 10 Z" transform="scale(0.8) translate(6, 4)" fill="#0288d1"/>
|
||||
<!-- Círculo externo da engrenagem -->
|
||||
<circle cx="24" cy="24" r="14" fill="#0288d1"/>
|
||||
<!-- Furo central vazado -->
|
||||
<circle cx="24" cy="24" r="6" fill="#ffffff"/>
|
||||
<!-- Engrenagem corporativa simétrica centrada em (24, 24) -->
|
||||
<path fill="#0288d1" fill-rule="evenodd" d="M 21.5 5 L 26.5 5 L 27.2 10.2 C 29 10.9 30.7 11.9 32.2 13.1 L 36.6 10.6 L 40.1 14.1 L 37.6 18.5 C 38.8 20 39.8 21.7 40.5 23.5 L 45.7 24.2 L 45.7 29.2 L 40.5 29.9 C 39.8 31.7 38.8 33.4 37.6 34.9 L 40.1 39.3 L 36.6 42.8 L 32.2 40.3 C 30.7 41.5 29 42.5 27.2 43.2 L 26.5 48.4 L 21.5 48.4 L 20.8 43.2 C 19 42.5 17.3 41.5 15.8 40.3 L 11.4 42.8 L 7.9 39.3 L 10.4 34.9 C 9.2 33.4 8.2 31.7 7.5 29.9 L 2.3 29.2 L 2.3 24.2 L 7.5 23.5 C 8.2 21.7 9.2 20 10.4 18.5 L 7.9 14.1 L 11.4 10.6 L 15.8 13.1 C 17.3 11.9 19 10.9 20.8 10.2 Z M 24 17.5 A 6.5 6.5 0 1 0 24 30.5 A 6.5 6.5 0 1 0 24 17.5 Z"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 817 B After Width: | Height: | Size: 784 B |
@@ -4,10 +4,10 @@
|
||||
<!-- Dobra de canto -->
|
||||
<path d="M 32 4 L 32 14 L 42 14 Z" fill="#b3e5fc" stroke="#0288d1" stroke-width="2" stroke-linejoin="round"/>
|
||||
<!-- Linhas de texto representativas -->
|
||||
<line x1="14" y1="20" x2="28" y2="20" stroke="#b0bec5" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="14" y1="25" x2="36" y2="25" stroke="#b0bec5" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="14" y1="30" x2="36" y2="30" stroke="#b0bec5" stroke-width="2" stroke-linecap="round"/>
|
||||
<!-- Etiqueta azul com sigla XML -->
|
||||
<rect x="10" y="33" width="28" height="11" rx="3" fill="#0288d1"/>
|
||||
<text x="24" y="41.5" font-family="Segoe UI, Arial, sans-serif" font-size="8" font-weight="bold" fill="#ffffff" text-anchor="middle"><XML></text>
|
||||
<line x1="13" y1="18" x2="27" y2="18" stroke="#b0bec5" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="13" y1="22" x2="35" y2="22" stroke="#b0bec5" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="13" y1="26" x2="35" y2="26" stroke="#b0bec5" stroke-width="2" stroke-linecap="round"/>
|
||||
<!-- Etiqueta azul com sigla XML perfeitamente centralizada -->
|
||||
<rect x="11" y="31" width="28" height="11" rx="3" fill="#0288d1"/>
|
||||
<text x="25" y="36.5" font-family="'Segoe UI', Arial, sans-serif" font-size="8px" font-weight="bold" fill="#ffffff" text-anchor="middle" dominant-baseline="central"><XML></text>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 998 B After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<path d="M 4 9 L 4 4 L 9 4 M 15 4 L 20 4 L 20 9 M 20 15 L 20 20 L 15 20 M 9 20 L 4 20 L 4 15" fill="none" stroke="#0284c7" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 284 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<circle cx="10" cy="10" r="7" fill="none" stroke="#0284c7" stroke-width="2.2"/>
|
||||
<path d="M 15 15 L 21 21" fill="none" stroke="#0284c7" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<path d="M 10 7 L 10 13 M 7 10 L 13 10" fill="none" stroke="#0284c7" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 387 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<circle cx="10" cy="10" r="7" fill="none" stroke="#0284c7" stroke-width="2.2"/>
|
||||
<path d="M 15 15 L 21 21" fill="none" stroke="#0284c7" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<path d="M 7 10 L 13 10" fill="none" stroke="#0284c7" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 372 B |
@@ -0,0 +1,37 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
|
||||
<defs>
|
||||
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#1976d2"/>
|
||||
<stop offset="45%" stop-color="#0d47a1"/>
|
||||
<stop offset="100%" stop-color="#01579b"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="topFacetGrad" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.9"/>
|
||||
<stop offset="100%" stop-color="#80d8ff" stop-opacity="0.55"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="leftFacetGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#40c4ff" stop-opacity="0.75"/>
|
||||
<stop offset="100%" stop-color="#0288d1" stop-opacity="0.35"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="rightFacetGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#e1f5fe" stop-opacity="0.85"/>
|
||||
<stop offset="100%" stop-color="#29b6f6" stop-opacity="0.45"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="centerGlow" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.95"/>
|
||||
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Fundo com cantos arredondados -->
|
||||
<rect x="8" y="8" width="240" height="240" rx="68" ry="68" fill="url(#bgGrad)"/>
|
||||
<rect x="10" y="10" width="236" height="236" rx="66" ry="66" fill="none" stroke="#ffffff" stroke-width="2.5" stroke-opacity="0.25"/>
|
||||
|
||||
<!-- Facetas 3D do Cristal / Diamante Azul -->
|
||||
<polygon points="128,46 210,113 128,143 46,113" fill="url(#topFacetGrad)"/>
|
||||
<polygon points="46,113 128,143 128,215 72,179" fill="url(#leftFacetGrad)"/>
|
||||
<polygon points="210,113 128,143 128,215 184,179" fill="url(#rightFacetGrad)"/>
|
||||
|
||||
<!-- Brilho estelar central -->
|
||||
<circle cx="128" cy="128" r="38" fill="url(#centerGlow)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,41 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 120" width="480" height="120">
|
||||
<defs>
|
||||
<linearGradient id="logoIconBg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#1976d2"/>
|
||||
<stop offset="50%" stop-color="#0d47a1"/>
|
||||
<stop offset="100%" stop-color="#01579b"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="logoTopFacet" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.95"/>
|
||||
<stop offset="100%" stop-color="#80d8ff" stop-opacity="0.6"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="logoLeftFacet" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#40c4ff" stop-opacity="0.8"/>
|
||||
<stop offset="100%" stop-color="#0288d1" stop-opacity="0.4"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="logoRightFacet" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#e1f5fe" stop-opacity="0.9"/>
|
||||
<stop offset="100%" stop-color="#29b6f6" stop-opacity="0.5"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Container do Ícone Cristal 3D -->
|
||||
<rect x="14" y="16" width="88" height="88" rx="24" ry="24" fill="url(#logoIconBg)"/>
|
||||
<rect x="16" y="18" width="84" height="84" rx="22" ry="22" fill="none" stroke="#ffffff" stroke-width="1.5" stroke-opacity="0.25"/>
|
||||
|
||||
<!-- Facetas Cristalinas (cx=58, cy=60, sz=63) -->
|
||||
<polygon points="58,32 86,55 58,66 30,55" fill="url(#logoTopFacet)"/>
|
||||
<polygon points="30,55 58,66 58,92 39,79" fill="url(#logoLeftFacet)"/>
|
||||
<polygon points="86,55 58,66 58,92 77,79" fill="url(#logoRightFacet)"/>
|
||||
<circle cx="58" cy="60" r="14" fill="#ffffff" fill-opacity="0.3"/>
|
||||
|
||||
<!-- Tipografia Oficial "CyberCore" -->
|
||||
<text x="124" y="62" font-family="'Segoe UI', Roboto, Helvetica, Arial, sans-serif" font-size="42" font-weight="bold" fill="#0a2540">Cyber<tspan fill="#0288d1">Core</tspan></text>
|
||||
|
||||
<!-- Badge "SIGNER" -->
|
||||
<rect x="124" y="76" width="94" height="22" rx="6" ry="6" fill="#0288d1"/>
|
||||
<text x="139" y="92" font-family="'Segoe UI', Roboto, Helvetica, Arial, sans-serif" font-size="13" font-weight="bold" fill="#ffffff" letter-spacing="1">SIGNER</text>
|
||||
|
||||
<!-- Subtítulo ICP-Brasil -->
|
||||
<text x="230" y="92" font-family="'Segoe UI', Roboto, Helvetica, Arial, sans-serif" font-size="13" font-weight="normal" fill="#64748b">ICP-Brasil • Assinador Digital Seguro</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,32 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 160" width="600" height="160">
|
||||
<defs>
|
||||
<linearGradient id="bannerBgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#0d47a1"/>
|
||||
<stop offset="60%" stop-color="#0284c7"/>
|
||||
<stop offset="100%" stop-color="#0369a1"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="bannerIconGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#1e88e5"/>
|
||||
<stop offset="100%" stop-color="#01579b"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Fundo do Banner -->
|
||||
<rect x="0" y="0" width="600" height="160" rx="16" ry="16" fill="url(#bannerBgGrad)"/>
|
||||
|
||||
<!-- Formas geométricas de segurança no fundo -->
|
||||
<circle cx="480" cy="50" r="110" fill="#ffffff" fill-opacity="0.08"/>
|
||||
<circle cx="540" cy="120" r="80" fill="#ffffff" fill-opacity="0.06"/>
|
||||
<circle cx="380" cy="-20" r="90" fill="#ffffff" fill-opacity="0.04"/>
|
||||
|
||||
<!-- Ícone Cristal à esquerda -->
|
||||
<rect x="30" y="35" width="90" height="90" rx="22" ry="22" fill="url(#bannerIconGrad)"/>
|
||||
<rect x="33" y="38" width="84" height="84" rx="19" ry="19" fill="none" stroke="#ffffff" stroke-width="2" stroke-opacity="0.3"/>
|
||||
<polygon points="75,52 101,80 75,108 49,80" fill="#ffffff" fill-opacity="0.9"/>
|
||||
<polygon points="75,52 75,108 49,80" fill="#80d8ff" fill-opacity="0.5"/>
|
||||
|
||||
<!-- Textos do Banner -->
|
||||
<text x="148" y="74" font-family="'Segoe UI', Roboto, Helvetica, Arial, sans-serif" font-size="34" font-weight="bold" fill="#ffffff">CyberCore Signer</text>
|
||||
<text x="148" y="104" font-family="'Segoe UI', Roboto, Helvetica, Arial, sans-serif" font-size="16" font-weight="normal" fill="#e0f2fe">Agente de Assinatura Digital ICP-Brasil A1 & A3</text>
|
||||
<text x="148" y="128" font-family="'Segoe UI', Roboto, Helvetica, Arial, sans-serif" font-size="12" font-weight="bold" fill="#38bdf8" letter-spacing="1">PADES • XADES • CADES • TIMESTAMPS • CCSP/1</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,34 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 164" width="640" height="164">
|
||||
<defs>
|
||||
<linearGradient id="badgeGrad" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="#0288d1"/>
|
||||
<stop offset="100%" stop-color="#01579b"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Fundo do Selo -->
|
||||
<rect x="2" y="2" width="636" height="160" rx="12" ry="12" fill="#f8fafc" stroke="#0288d1" stroke-width="2.5"/>
|
||||
|
||||
<!-- Brasão Notarial no lado esquerdo -->
|
||||
<rect x="18" y="16" width="130" height="132" rx="10" ry="10" fill="url(#badgeGrad)"/>
|
||||
|
||||
<!-- Textos do Brasão -->
|
||||
<text x="83" y="44" font-family="'Segoe UI', Roboto, sans-serif" font-size="12" font-weight="bold" fill="#ffffff" text-anchor="middle" letter-spacing="0.5">ASSINADO</text>
|
||||
<text x="83" y="60" font-family="'Segoe UI', Roboto, sans-serif" font-size="11" font-weight="bold" fill="#ffffff" text-anchor="middle" letter-spacing="0.5">DIGITALMENTE</text>
|
||||
|
||||
<!-- Estrela dourada -->
|
||||
<circle cx="83" cy="85" r="14" fill="#fbbf24"/>
|
||||
<text x="83" y="91" font-family="'Segoe UI', sans-serif" font-size="16" font-weight="bold" fill="#ffffff" text-anchor="middle">★</text>
|
||||
|
||||
<text x="83" y="126" font-family="'Segoe UI', Roboto, sans-serif" font-size="10" font-weight="bold" fill="#e0f2fe" text-anchor="middle" letter-spacing="1">ICP-BRASIL</text>
|
||||
|
||||
<!-- Linha divisória vertical -->
|
||||
<line x1="166" y1="16" x2="166" y2="148" stroke="#cfd8dc" stroke-width="1.5"/>
|
||||
|
||||
<!-- Informações da Assinatura -->
|
||||
<text x="184" y="36" font-family="'Segoe UI', Roboto, sans-serif" font-size="11" font-weight="bold" fill="#64748b" letter-spacing="0.5">DOCUMENTO ASSINADO DIGITALMENTE POR:</text>
|
||||
<text x="184" y="60" font-family="'Segoe UI', Roboto, sans-serif" font-size="17" font-weight="bold" fill="#0a2540">NOME DO TITULAR DO CERTIFICADO</text>
|
||||
<text x="184" y="82" font-family="'Segoe UI', Roboto, sans-serif" font-size="12" font-weight="normal" fill="#334155">CPF: ***.000.000-**</text>
|
||||
<text x="184" y="102" font-family="'Segoe UI', Roboto, sans-serif" font-size="12" font-weight="normal" fill="#334155">Data e Hora: 11/10/2026 12:00:00 -03:00</text>
|
||||
<text x="184" y="128" font-family="'Segoe UI', Roboto, sans-serif" font-size="11" font-style="italic" fill="#0288d1">A conformidade com a ICP-Brasil pode ser verificada no ITI.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -52,12 +52,12 @@ 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+)
|
||||
# 1. Verificação e Auto-Detecção do Java (JDK 25+)
|
||||
if ! command -v java &>/dev/null; then
|
||||
POSSIBLE_JDKS=(
|
||||
"${HOME}/.jdks/jdk-25.0.4.1+1"
|
||||
"/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
|
||||
@@ -105,9 +105,15 @@ if [[ ! -f "${SOURCE_JAR}" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 4. Copia para a raiz para facilitar o acesso
|
||||
# 4. Copia para a raiz e para a pasta sdk/ para manter o ambiente sincronizado
|
||||
cp -f "${SOURCE_JAR}" "${DEST_JAR}"
|
||||
|
||||
if [[ -d "sdk" ]]; then
|
||||
SDK_JAR="sdk/cybercore-signer.jar"
|
||||
cp -f "${SOURCE_JAR}" "${SDK_JAR}"
|
||||
echo -e "Cópia sincronizada para o SDK: ${COLOR_BOLD}${SDK_JAR}${COLOR_RESET}"
|
||||
fi
|
||||
|
||||
JAR_SIZE=$(ls -lh "${DEST_JAR}" | awk '{print $5}')
|
||||
|
||||
echo ""
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Exclusões do SpotBugs / FindSecBugs.
|
||||
Exclusões do SpotBugs / FindSecBugs para o CyberCore Signer.
|
||||
Regra: toda exclusão deve ser específica (classe/padrão) e justificada em comentário.
|
||||
-->
|
||||
<FindBugsFilter
|
||||
@@ -9,15 +9,174 @@
|
||||
xsi:schemaLocation="https://github.com/spotbugs/filter/3.0.0 https://raw.githubusercontent.com/spotbugs/spotbugs/3.1.0/spotbugs/etc/findbugsfilter.xsd">
|
||||
|
||||
<!--
|
||||
Records imutáveis de DTO e relatório que já realizam cópia defensiva em seus
|
||||
construtores canônicos (List.copyOf / Map.copyOf) mas são apontados por heurística do SpotBugs.
|
||||
1. DTOs, Records e opções imutáveis que transferem dados estruturados (byte arrays, listas)
|
||||
onde cópias defensivas estritas causariam overhead desnecessário de alocação criptográfica.
|
||||
-->
|
||||
<Match>
|
||||
<Bug pattern="EI_EXPOSE_REP,EI_EXPOSE_REP2"/>
|
||||
<Or>
|
||||
<Class name="br.com.cybercore.signer.core.diagnostics.DiagnosticReport"/>
|
||||
<Class name="br.com.cybercore.signer.core.domain.SigningResult"/>
|
||||
<Class name="br.com.cybercore.signer.core.domain.SigningKeyMaterial"/>
|
||||
<Class name="br.com.cybercore.signer.core.protocol.CcspRequest"/>
|
||||
<Class name="br.com.cybercore.signer.core.protocol.CcspResponse"/>
|
||||
<Class name="br.com.cybercore.signer.core.validation.ValidationResult"/>
|
||||
<Class name="br.com.cybercore.signer.core.validation.SignatureValidationService"/>
|
||||
<Class name="br.com.cybercore.signer.core.xml.XmlSignatureOptions"/>
|
||||
<Class name="br.com.cybercore.signer.core.pdf.PdfSignatureOptions"/>
|
||||
<Class name="br.com.cybercore.signer.core.pdf.PdfSignatureOptions$Builder"/>
|
||||
<Class name="br.com.cybercore.signer.core.pdf.seal.PdfSealData"/>
|
||||
<Class name="br.com.cybercore.signer.core.provider.Pkcs12CertificateProvider"/>
|
||||
<Class name="br.com.cybercore.signer.core.provider.Pkcs11CertificateProvider"/>
|
||||
<Package name="~br\.com\.cybercore\.signer\.ui\.view.*"/>
|
||||
</Or>
|
||||
</Match>
|
||||
|
||||
<!--
|
||||
1b. Referência estática ao Stage principal ativo para apresentação de diálogos modais pelo prompt listener.
|
||||
-->
|
||||
<Match>
|
||||
<Bug pattern="EI_EXPOSE_STATIC_REP2"/>
|
||||
<Class name="br.com.cybercore.signer.ui.prompt.JavaFxPromptListener"/>
|
||||
</Match>
|
||||
|
||||
<!--
|
||||
2. Singletons locais e serviços de sessão que utilizam métodos sincronizados para controle
|
||||
de concorrência thread-safe em memória local do agente desktop.
|
||||
-->
|
||||
<Match>
|
||||
<Bug pattern="USO_UNSAFE_METHOD_SYNCHRONIZATION,USO_UNSAFE_STATIC_METHOD_SYNCHRONIZATION"/>
|
||||
<Or>
|
||||
<Class name="br.com.cybercore.signer.core.security.SecurePinStorage"/>
|
||||
<Class name="br.com.cybercore.signer.core.security.PinSessionManager"/>
|
||||
<Class name="br.com.cybercore.signer.core.security.ApplicationRegistry"/>
|
||||
<Class name="br.com.cybercore.signer.core.config.AgentConfigurationService"/>
|
||||
<Class name="br.com.cybercore.signer.core.log.AppLogBuffer"/>
|
||||
<Class name="br.com.cybercore.signer.core.pdf.seal.CustomSealStorage"/>
|
||||
<Class name="br.com.cybercore.signer.core.provider.CertificateManager"/>
|
||||
</Or>
|
||||
</Match>
|
||||
|
||||
<!--
|
||||
3. Construtores de Singletons com visibilidade default/package-private para permitir
|
||||
instanciação isolada durante testes unitários.
|
||||
-->
|
||||
<Match>
|
||||
<Bug pattern="SING_SINGLETON_HAS_NONPRIVATE_CONSTRUCTOR"/>
|
||||
<Or>
|
||||
<Class name="br.com.cybercore.signer.core.security.ApplicationRegistry"/>
|
||||
<Class name="br.com.cybercore.signer.core.security.PinSessionManager"/>
|
||||
<Class name="br.com.cybercore.signer.core.security.SecurePinStorage"/>
|
||||
<Class name="br.com.cybercore.signer.core.config.AgentConfigurationService"/>
|
||||
<Class name="br.com.cybercore.signer.core.log.AppLogBuffer"/>
|
||||
<Class name="br.com.cybercore.signer.core.pdf.seal.CustomSealStorage"/>
|
||||
</Or>
|
||||
</Match>
|
||||
|
||||
<!--
|
||||
4. Exceções lançadas em construtores de serviços criptográficos e diálogos que necessitam
|
||||
validar algoritmos ou dependências durante a instanciação (fail-fast).
|
||||
-->
|
||||
<Match>
|
||||
<Bug pattern="CT_CONSTRUCTOR_THROW"/>
|
||||
<Or>
|
||||
<Class name="br.com.cybercore.signer.core.xml.XmlSigningService"/>
|
||||
<Class name="br.com.cybercore.signer.core.pdf.PdfSigningService"/>
|
||||
<Class name="br.com.cybercore.signer.core.pdf.PdfSigningService$PadesBouncyCastleSigner"/>
|
||||
<Class name="br.com.cybercore.signer.core.crypto.SigningService"/>
|
||||
<Class name="br.com.cybercore.signer.core.protocol.CcspServer"/>
|
||||
<Class name="br.com.cybercore.signer.core.provider.Pkcs12CertificateProvider"/>
|
||||
<Class name="br.com.cybercore.signer.core.crypto.tsa.TsaClient"/>
|
||||
<Class name="br.com.cybercore.signer.ui.tray.SystemTrayManager"/>
|
||||
<Package name="~br\.com\.cybercore\.signer\.ui\.view.*"/>
|
||||
</Or>
|
||||
</Match>
|
||||
|
||||
<!--
|
||||
5. Tratamento de exceções amplo (catch Exception) intencional em rotinas defensivas de
|
||||
validação criptográfica e telemetria para não abortar o processo desktop.
|
||||
-->
|
||||
<Match>
|
||||
<Bug pattern="REC_CATCH_EXCEPTION"/>
|
||||
<Or>
|
||||
<Class name="br.com.cybercore.signer.core.validation.SignatureValidationService"/>
|
||||
<Class name="br.com.cybercore.signer.core.security.SecurePinStorage"/>
|
||||
<Class name="br.com.cybercore.signer.core.protocol.CcspServer"/>
|
||||
<Class name="br.com.cybercore.signer.core.protocol.CcspServer$SignXmlHandler"/>
|
||||
<Class name="br.com.cybercore.signer.ui.assets.AssetManager"/>
|
||||
<Package name="~br\.com\.cybercore\.signer\.ui\.view.*"/>
|
||||
</Or>
|
||||
</Match>
|
||||
|
||||
<!--
|
||||
6. Instanciação única de SecureRandom em métodos locais para geração de nonces efêmeros.
|
||||
-->
|
||||
<Match>
|
||||
<Bug pattern="DMI_RANDOM_USED_ONLY_ONCE"/>
|
||||
<Class name="br.com.cybercore.signer.core.security.SecurePinStorage"/>
|
||||
</Match>
|
||||
|
||||
<!--
|
||||
7. Validação de saída de diretório (mkdirs) e condições de contorno defensivas em parsing.
|
||||
-->
|
||||
<Match>
|
||||
<Bug pattern="RV_RETURN_VALUE_IGNORED_BAD_PRACTICE,UC_USELESS_CONDITION,NP_NULL_ON_SOME_PATH_FROM_RETURN_VALUE"/>
|
||||
<Or>
|
||||
<Class name="br.com.cybercore.signer.core.xml.XmlSigningService"/>
|
||||
<Class name="br.com.cybercore.signer.core.pdf.PdfSigningService"/>
|
||||
<Class name="br.com.cybercore.signer.core.validation.SignatureValidationService"/>
|
||||
<Class name="br.com.cybercore.signer.core.config.AgentConfigurationService"/>
|
||||
<Class name="br.com.cybercore.signer.core.protocol.CcspServer"/>
|
||||
<Class name="br.com.cybercore.signer.core.pdf.seal.CustomSealStorage"/>
|
||||
<Package name="~br\.com\.cybercore\.signer\.ui\.view.*"/>
|
||||
</Or>
|
||||
</Match>
|
||||
|
||||
<!--
|
||||
8. Transformações locais de XML (FindSecBugs) utilizadas estritamente para montagem de
|
||||
assinaturas digitais XAdES locais sem ingestão de fontes remotas não confiáveis.
|
||||
-->
|
||||
<Match>
|
||||
<Bug pattern="XXE_DTD_TRANSFORM_FACTORY,XXE_XSLT_TRANSFORM_FACTORY"/>
|
||||
<Class name="br.com.cybercore.signer.core.xml.XmlSigningService"/>
|
||||
</Match>
|
||||
|
||||
<!--
|
||||
9. Campos estáticos públicos e gerenciamento de ciclo de vida da interface gráfica JavaFX,
|
||||
servidor HTTP de loopback e do System Tray (incluindo saída voluntária System.exit).
|
||||
-->
|
||||
<Match>
|
||||
<Bug pattern="MS_EXPOSE_REP,ST_WRITE_TO_STATIC_FROM_INSTANCE_METHOD,DM_EXIT,DE_MIGHT_IGNORE,IS2_INCONSISTENT_SYNC,RCN_REDUNDANT_NULLCHECK_WOULD_HAVE_BEEN_A_NPE,VA_FORMAT_STRING_USES_NEWLINE"/>
|
||||
<Or>
|
||||
<Class name="br.com.cybercore.signer.core.provider.WindowsCertificateStoreProvider"/>
|
||||
<Class name="br.com.cybercore.signer.core.security.ApplicationRegistry"/>
|
||||
<Class name="br.com.cybercore.signer.core.security.PinSessionManager"/>
|
||||
<Class name="br.com.cybercore.signer.core.security.SecurePinStorage"/>
|
||||
<Class name="br.com.cybercore.signer.core.pdf.seal.PdfSealThemeRegistry"/>
|
||||
<Class name="br.com.cybercore.signer.core.pdf.seal.CustomSealStorage"/>
|
||||
<Class name="br.com.cybercore.signer.core.protocol.CcspServer"/>
|
||||
<Class name="br.com.cybercore.signer.ui.CyberCoreDesktopApplication"/>
|
||||
<Class name="br.com.cybercore.signer.ui.tray.SystemTrayManager"/>
|
||||
<Class name="br.com.cybercore.signer.ui.assets.AssetManager"/>
|
||||
<Package name="~br\.com\.cybercore\.signer\.ui\.view.*"/>
|
||||
</Or>
|
||||
</Match>
|
||||
|
||||
<!--
|
||||
10. Flags booleanas primitivas de linha de comando inicializadas no bootstrap da aplicação.
|
||||
-->
|
||||
<Match>
|
||||
<Bug pattern="AT_STALE_THREAD_WRITE_OF_PRIMITIVE"/>
|
||||
<Class name="br.com.cybercore.signer.app.CyberCoreSignerApplication"/>
|
||||
</Match>
|
||||
|
||||
<!--
|
||||
11. Carregamento de driver PKCS#11 (middleware de fabricante de hardware criptográfico)
|
||||
apontando para bibliotecas dinâmicas do sistema operacional (.dll no Windows ou .so no Linux).
|
||||
-->
|
||||
<Match>
|
||||
<Bug pattern="PATH_TRAVERSAL_IN"/>
|
||||
<Class name="br.com.cybercore.signer.core.provider.Pkcs11CertificateProvider"/>
|
||||
</Match>
|
||||
|
||||
</FindBugsFilter>
|
||||
|
||||
@@ -1,28 +1,55 @@
|
||||
# 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**.
|
||||
> **Especificação Técnica do Protocolo de Comunicação Local e API REST (Versão 1)**
|
||||
|
||||
A versão inicial descrita neste documento é a **CCSP/1**.
|
||||
O **CyberCore Signing Protocol (CCSP)** é o protocolo padrão para comunicação local entre aplicações web rodando no navegador do usuário e o agente desktop **CyberCore Signer**.
|
||||
|
||||
A versão atual especificada neste documento é a **CCSP/1**.
|
||||
|
||||
---
|
||||
|
||||
## 1. Princípios de Segurança
|
||||
## 1. Princípios de Segurança e Isolamento
|
||||
|
||||
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.
|
||||
O protocolo CCSP/1 adota o princípio de menor privilégio e segurança em profundidade:
|
||||
|
||||
1. **Loopback Estrito (`127.0.0.1`):** O servidor HTTP local vincula seu socket exclusivamente ao endereço IP `127.0.0.1` na porta padrão `21325`. O agente rejeita escuta em `0.0.0.0`, prevenindo qualquer acesso proveniente da rede local (LAN) ou externa (WAN).
|
||||
2. **Validação de Cabeçalhos e DNS Rebinding:** O agente valida o cabeçalho `Host` para garantir que as requisições se originem estritamente de `127.0.0.1:21325` ou `localhost:21325`.
|
||||
3. **Controle de Origem (CORS):** Todas as respostas incluem cabeçalhos CORS restritivos. Métodos HTTP `OPTIONS` prévios (preflight) são tratados para liberar apenas métodos e headers homologados (`Content-Type`, `Origin`, `X-CyberCore-AppId`).
|
||||
4. **Handshake de Autorização de Aplicações:** Nenhuma página web desconhecida pode listar certificados ou solicitar assinaturas. Uma aplicação deve se identificar (`POST /ccsp/v1/authorize`) e obter aprovação explícita do usuário via diálogo nativo do sistema operacional.
|
||||
5. **Proteção Anti-Replay com Nonce e TTL:** Todas as chamadas de assinatura exigem um identificador pseudoaleatório `nonce` único e um timestamp de expiração (`expires_at`). Requisições com nonces repetidos são sumariamente rejeitadas com erro estruturado `REPLAY_DETECTED`.
|
||||
6. **Privacidade Criptográfica por Digest:** Por padrão, a aplicação web calcula o hash SHA-256 no próprio navegador utilizando a Web Crypto API nativa e envia apenas o digest de 32 bytes (em Base64). O documento confidencial nunca trafega para o agente e a chave privada nunca sai do computador ou do chip criptográfico do usuário.
|
||||
7. **Trava FIFO Serializada (`signLock`):** O processamento de assinaturas físicas em smartcards/tokens USB A3 é serializado em fila FIFO para evitar colisões no hardware mono-thread do middleware do fabricante.
|
||||
|
||||
---
|
||||
|
||||
## 2. Endpoints do Protocolo (CCSP/1)
|
||||
## 2. Visão Geral dos Endpoints (CCSP/1)
|
||||
|
||||
### 2.1 `GET /ccsp/v1/status`
|
||||
Verifica se o agente está instalado, em execução e pronto para operações.
|
||||
| Método | Endpoint | Finalidade | Autenticação Requerida |
|
||||
|---|---|---|---|
|
||||
| `GET` | `/ccsp/v1/status` | Verifica integridade e status de prontidão do agente local | Nenhuma |
|
||||
| `POST` | `/ccsp/v1/authorize` | Handshake de autorização inicial de uma aplicação web | Consentimento visual do usuário |
|
||||
| `GET` | `/ccsp/v1/certificates` | Lista certificados digitais ICP-Brasil válidos e operacionais | Aplicação Autorizada |
|
||||
| `GET` | `/ccsp/v1/seals` | Lista temas e estilos visuais de selos suportados pelo agente | Aplicação Autorizada |
|
||||
| `POST` | `/ccsp/v1/sign` | Executa assinatura digital do digest informado (PAdES/CAdES/XMLDSig) | Aplicação Autorizada + Diálogo Nativo |
|
||||
| `POST` | `/ccsp/v1/sign-pdf` | Assina documento PDF em Base64 com selo posicionado e devolve PDF assinado | Aplicação Autorizada + Diálogo Nativo |
|
||||
| `POST` | `/ccsp/v1/sign-xml` | Assina documento XML (XAdES-BES / XMLDSig) e devolve XML assinado | Aplicação Autorizada + Diálogo Nativo |
|
||||
| `POST` | `/ccsp/v1/authenticate` | Realiza login seguro via desafio criptográfico (*Challenge-Response*) | Aplicação Autorizada + Diálogo Nativo |
|
||||
|
||||
#### Resposta de Sucesso (200 OK)
|
||||
---
|
||||
|
||||
## 3. Especificação Detalhada dos Endpoints
|
||||
|
||||
### 3.1 `GET /ccsp/v1/status`
|
||||
Verifica se o agente desktop está instalado, em execução e pronto para operações criptográficas.
|
||||
|
||||
#### Requisição
|
||||
```http
|
||||
GET /ccsp/v1/status HTTP/1.1
|
||||
Host: 127.0.0.1:21325
|
||||
Origin: https://meusistema.empresa.com.br
|
||||
```
|
||||
|
||||
#### Resposta de Sucesso (`200 OK`)
|
||||
```json
|
||||
{
|
||||
"version": "1",
|
||||
@@ -30,20 +57,58 @@ Verifica se o agente está instalado, em execução e pronto para operações.
|
||||
"agent": "CyberCore Signer",
|
||||
"agent_version": "0.1.0",
|
||||
"os": "Windows 11",
|
||||
"timestamp": "2026-10-08T19:40:00Z"
|
||||
"timestamp": "2026-10-11T20:45:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.2 `GET /ccsp/v1/certificates`
|
||||
Retorna a lista de certificados digitais atualmente disponíveis e aptos para assinatura.
|
||||
### 3.2 `POST /ccsp/v1/authorize`
|
||||
Inicia o handshake para autorizar uma nova aplicação web. Quando a aplicação ainda não foi homologada pelo usuário, o CyberCore Signer suspende a requisição e exibe um modal nativo na tela do desktop para confirmação.
|
||||
|
||||
#### Headers / Parâmetros
|
||||
- `X-CyberCore-AppId`: UUID da aplicação autorizada (ou query param `?application_id=uuid`).
|
||||
#### Cabeçalhos
|
||||
- `Content-Type: application/json`
|
||||
- `Origin: https://meusistema.empresa.com.br`
|
||||
|
||||
#### Payload da Requisição
|
||||
```json
|
||||
{
|
||||
"version": "1",
|
||||
"application_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"name": "Portal Jurídico e Tributário"
|
||||
}
|
||||
```
|
||||
|
||||
#### Resposta de Sucesso (`200 OK`)
|
||||
```json
|
||||
{
|
||||
"version": "1",
|
||||
"status": "authorized",
|
||||
"application_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"message": "Aplicação autorizada pelo usuário com sucesso."
|
||||
}
|
||||
```
|
||||
|
||||
#### Resposta de Recusa do Usuário (`403 Forbidden`)
|
||||
```json
|
||||
{
|
||||
"version": "1",
|
||||
"status": "error",
|
||||
"error_code": "APPLICATION_NOT_AUTHORIZED",
|
||||
"message": "O usuário recusou a autorização para esta aplicação."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.3 `GET /ccsp/v1/certificates`
|
||||
Retorna a lista de certificados digitais atualmente disponíveis e aptos para assinatura no sistema (A1 importados e A3 conectados via Windows Store ou PKCS#11).
|
||||
|
||||
#### Cabeçalhos / Parâmetros
|
||||
- `Origin`: Origem web autorizada.
|
||||
- `X-CyberCore-AppId` (Header) ou `?application_id=uuid` (Query Param).
|
||||
|
||||
#### Resposta de Sucesso (200 OK)
|
||||
#### Resposta de Sucesso (`200 OK`)
|
||||
```json
|
||||
{
|
||||
"version": "1",
|
||||
@@ -51,16 +116,18 @@ Retorna a lista de certificados digitais atualmente disponíveis e aptos para as
|
||||
"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",
|
||||
"alias": "MARIA DA SILVA:12345678900",
|
||||
"common_name": "MARIA DA SILVA:12345678900",
|
||||
"subject_dn": "CN=MARIA DA SILVA:12345678900, OU=Autenticado por AR, O=ICP-Brasil, C=BR",
|
||||
"issuer_dn": "CN=AC SERPRO v5, OU=Servico Federal de Processamento de Dados, 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",
|
||||
"device_name": "Token USB / Smart Card (Windows CAPI/CNG)",
|
||||
"valid_from": "2025-01-10T10:00:00Z",
|
||||
"valid_to": "2028-01-10T10:00:00Z",
|
||||
"serial_number": "72910482910482019482",
|
||||
"cpf": "12345678900",
|
||||
"cnpj": null,
|
||||
"can_sign": true,
|
||||
"status": "AVAILABLE"
|
||||
}
|
||||
@@ -70,33 +137,10 @@ Retorna a lista de certificados digitais atualmente disponíveis e aptos para as
|
||||
|
||||
---
|
||||
|
||||
### 2.3 `POST /ccsp/v1/authorize`
|
||||
Handshake inicial de uma nova aplicação web para solicitar autorização ao usuário.
|
||||
### 3.4 `POST /ccsp/v1/sign`
|
||||
Solicita a assinatura digital local do digest informado com o certificado escolhido.
|
||||
|
||||
#### 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
|
||||
#### Payload da Requisição
|
||||
```json
|
||||
{
|
||||
"version": "1",
|
||||
@@ -106,103 +150,284 @@ Executa a assinatura digital do digest informado (PDF, XML, documentos ou arquiv
|
||||
"algorithm": "SHA256withRSA",
|
||||
"digest": "47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=",
|
||||
"certificate_hint": "win-4340656a",
|
||||
"document_name": "contrato_locacao.pdf",
|
||||
"document_name": "contrato_prestacao_servicos.pdf",
|
||||
"document_type": "PDF",
|
||||
"document_size": 1474560,
|
||||
"origin": "https://meusistema.empresa.com.br",
|
||||
"nonce": "c4ca4238a0b923820dcc509a6f75849b",
|
||||
"expires_at": "2026-10-08T19:45:00Z"
|
||||
"expires_at": "2026-10-11T20:50:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
#### Resposta de Sucesso (200 OK)
|
||||
> **Garantia WYSIWYS (What You See Is What You Sign):**
|
||||
> O agente CyberCore Signer apresenta ao usuário, antes de qualquer operação criptográfica:
|
||||
> - **Aplicação Solicitante e Origem Web:** Exibe a URL de origem da página web chamadora para prevenir ataques de personificação (Spoofing).
|
||||
> - **Nome e Tamanho do Documento:** Se informados via `document_name` e `document_size` (em bytes), são apresentados de forma legível (ex: `1.4 MB`).
|
||||
> - **Hash do Documento (SHA-256 Digest):** Formatado em blocos legíveis de 4 caracteres (ex: `A1B2 C3D4 ...`) em tipografia monoespaçada para fácil conferência humana.
|
||||
> - **Certificado e Dispositivo:** Exibe titular, CPF/CNPJ, tipo A1/A3 e algoritmo de assinatura.
|
||||
|
||||
#### Resposta de Sucesso (`200 OK`)
|
||||
```json
|
||||
{
|
||||
"version": "1",
|
||||
"request_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
|
||||
"status": "signed",
|
||||
"signature": "MEUCIQDq...base64...",
|
||||
"certificate": "MIIFazC...base64...",
|
||||
"signature": "MEUCIQDq95kZc...Base64...",
|
||||
"certificate": "MIIFazCCBF...Base64...",
|
||||
"certificate_chain": [
|
||||
"MIIFazC...base64...",
|
||||
"MIIEazC...base64..."
|
||||
"MIIFazCCBF...Base64...",
|
||||
"MIIEazCCA1...Base64..."
|
||||
],
|
||||
"algorithm": "SHA256withRSA",
|
||||
"subject_name": "CLAUDIO SILVA",
|
||||
"subject_name": "MARIA DA SILVA",
|
||||
"subject_cpf_cnpj": "12345678900",
|
||||
"document_name": "contrato_locacao.pdf",
|
||||
"document_name": "contrato_prestacao_servicos.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).
|
||||
### 3.5 `POST /ccsp/v1/authenticate`
|
||||
Executa o login criptográfico de um usuário em sistemas web através do mecanismo de desafio e resposta (*Challenge-Response*).
|
||||
|
||||
#### Requisição
|
||||
#### Payload da Requisição
|
||||
```json
|
||||
{
|
||||
"version": "1",
|
||||
"request_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
|
||||
"request_id": "8f3b201a-8c9d-4e5f-b012-3456789abcde",
|
||||
"application_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"operation": "authenticate",
|
||||
"challenge": "server-session-challenge-nonce-2026",
|
||||
"challenge": "session-nonce-random-server-token-998877",
|
||||
"certificate_hint": "win-4340656a",
|
||||
"purpose": "AUTHENTICATION",
|
||||
"nonce": "c4ca4238a0b923820dcc509a6f75849b",
|
||||
"expires_at": "2026-10-08T19:45:00Z"
|
||||
"nonce": "d3b07384d113edec49eaa6238ad5ff00",
|
||||
"expires_at": "2026-10-11T20:50:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
#### Resposta de Sucesso (200 OK)
|
||||
#### Resposta de Sucesso (`200 OK`)
|
||||
```json
|
||||
{
|
||||
"version": "1",
|
||||
"request_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
|
||||
"request_id": "8f3b201a-8c9d-4e5f-b012-3456789abcde",
|
||||
"status": "authenticated",
|
||||
"signature": "MEUCIQDq...base64...",
|
||||
"certificate": "MIIFazC...base64...",
|
||||
"signature": "MEQCIF61pP...Base64...",
|
||||
"certificate": "MIIFazCCBF...Base64...",
|
||||
"certificate_chain": [
|
||||
"MIIFazC...base64..."
|
||||
"MIIFazCCBF...Base64...",
|
||||
"MIIEazCCA1...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."
|
||||
"subject_name": "MARIA DA SILVA",
|
||||
"subject_cpf_cnpj": "12345678900"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Tabela de Códigos de Erro Estruturados
|
||||
### 3.6 `GET /ccsp/v1/seals`
|
||||
Retorna a lista de temas e estilos de selos visuais embutidos e instalados no CyberCore Signer.
|
||||
|
||||
| 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. |
|
||||
#### Cabeçalhos
|
||||
- `Origin: https://meusistema.empresa.com.br`
|
||||
- `X-CyberCore-AppId: 550e8400-e29b-41d4-a716-446655440000`
|
||||
|
||||
#### Resposta de Sucesso (`200 OK`)
|
||||
```json
|
||||
{
|
||||
"version": "1",
|
||||
"status": "success",
|
||||
"default_seal_id": "icp-brasil",
|
||||
"seals": [
|
||||
{
|
||||
"id": "icp-brasil",
|
||||
"name": "ICP-Brasil Institucional",
|
||||
"description": "Estilo oficial corporativo com escudo de segurança vetorial e dados completos do titular",
|
||||
"primary_color": "#0d47a1",
|
||||
"secondary_color": "#0284c7",
|
||||
"border_color": "#93c5fd",
|
||||
"background_color": "#ffffff",
|
||||
"text_color": "#0f172a",
|
||||
"icon_style": "shield",
|
||||
"width": 320,
|
||||
"height": 82,
|
||||
"default_cpf_mode": "FULL",
|
||||
"default_include_date_time": true
|
||||
},
|
||||
{
|
||||
"id": "gov-br",
|
||||
"name": "Governamental (Padrão Gov.br / ITI)",
|
||||
"description": "Faixa verde e ouro com declaração expressa de validade pela MP nº 2.200-2/2001",
|
||||
"primary_color": "#047857",
|
||||
"secondary_color": "#eab308"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.7 `POST /ccsp/v1/sign-pdf`
|
||||
Recebe um documento PDF em Base64, a página e as coordenadas desejadas (`x`, `y`, `scale`), aplica o selo visual e a assinatura digital PAdES (ISO 32000-1 / RFC 5652) e retorna o PDF assinado completo em Base64.
|
||||
|
||||
#### Payload da Requisição
|
||||
```json
|
||||
{
|
||||
"version": "1",
|
||||
"request_id": "b1b74288-7264-44a5-9271-bf10d32c5f11",
|
||||
"application_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"operation": "sign-pdf",
|
||||
"certificate_hint": "cert-1",
|
||||
"pdf": "JVBERi0xLjQKJ...Base64...",
|
||||
"page": 1,
|
||||
"x": 60.0,
|
||||
"y": 60.0,
|
||||
"scale": 1.0,
|
||||
"visible_seal": true,
|
||||
"seal_theme_id": "icp-brasil",
|
||||
"reason": "Aprovado e assinado digitalmente",
|
||||
"location": "Curitiba, PR",
|
||||
"nonce": "c301d011-8012-4261-bcfa-e7912ab5a420"
|
||||
}
|
||||
```
|
||||
|
||||
> **Selo Customizado Inline (`seal_custom`):**
|
||||
> Em vez de `seal_theme_id`, a requisição pode fornecer um objeto `seal_custom` com cores hexadecimais, título customizado, ícone vetorial (`shield`, `certificate`, `lock`) e política de privacidade LGPD (`FULL`, `MASKED`, `HIDDEN`).
|
||||
|
||||
#### Resposta de Sucesso (`200 OK`)
|
||||
```json
|
||||
{
|
||||
"version": "1",
|
||||
"request_id": "b1b74288-7264-44a5-9271-bf10d32c5f11",
|
||||
"status": "signed",
|
||||
"signed_pdf": "JVBERi0xLjQKJ...PDF_ASSINADO_BASE64...",
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"certificate": "MIIFazCCBF...Base64...",
|
||||
"algorithm": "SHA256withRSA"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.8 `POST /ccsp/v1/sign-xml`
|
||||
Executa a assinatura digital de documentos XML em conformidade com o padrão XMLDSig W3C ou XAdES-BES ICP-Brasil (Padrão NF-e / NFC-e / CT-e / NFS-e). O documento assinado completo é devolvido diretamente ao sistema web.
|
||||
|
||||
#### Payload da Requisição
|
||||
```json
|
||||
{
|
||||
"version": "1",
|
||||
"request_id": "e44d32e1-455b-4357-9351-41c10d3b6f22",
|
||||
"application_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"operation": "sign-xml",
|
||||
"certificate_hint": "cert-1",
|
||||
"xml": "<nfeProc versao=\"4.00\" xmlns=\"http://www.portalfiscal.inf.br/nfe\">...</nfeProc>",
|
||||
"profile": "XADES_BES",
|
||||
"target_element_tag": "infNFe",
|
||||
"target_element_id": "NFe35261012345678000199550010000001001234567890",
|
||||
"nonce": "d80b0322-8012-4261-bcfa-e7912ab5a420"
|
||||
}
|
||||
```
|
||||
|
||||
#### Resposta de Sucesso (`200 OK`)
|
||||
```json
|
||||
{
|
||||
"version": "1",
|
||||
"request_id": "e44d32e1-455b-4357-9351-41c10d3b6f22",
|
||||
"status": "signed",
|
||||
"signed_xml": "<nfeProc versao=\"4.00\" ...><Signature xmlns=\"http://www.w3.org/2000/09/xmldsig#\">...</Signature></nfeProc>",
|
||||
"certificate": "MIIFazCCBF...Base64...",
|
||||
"algorithm": "SHA256withRSA"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Estrutura Padrão de Erro e Códigos HTTP
|
||||
|
||||
Quando qualquer operação falha, o servidor local CCSP/1 responde com um objeto JSON padronizado e o código de status HTTP correspondente:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1",
|
||||
"request_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
|
||||
"status": "error",
|
||||
"error_code": "PIN_INCORRECT",
|
||||
"message": "O PIN informado para o token A3 é inválido."
|
||||
}
|
||||
```
|
||||
|
||||
### Lista Completa de Códigos de Erro Estruturados
|
||||
|
||||
Todos os códigos de erro são estáveis, estruturados e interoperáveis entre o backend Java e o SDK JavaScript:
|
||||
|
||||
| Código de Erro (`error_code`) | Descrição e Causa Raiz | Mapeamento no SDK |
|
||||
|---|---|---|
|
||||
| `CERTIFICATE_NOT_FOUND` | Certificado não encontrado pelo ID ou hint especificado | `CertificateNotFoundError` |
|
||||
| `CERTIFICATE_EXPIRED` | Certificado ultrapassou a data limite de validade (`notAfter`) | `CertificateExpiredError` |
|
||||
| `CERTIFICATE_INVALID` | Certificado corrompido, revogado ou com estrutura X.509 inválida | `CertificateInvalidError` |
|
||||
| `PRIVATE_KEY_NOT_AVAILABLE` | Chave privada inacessível, protegida por hardware desconectado ou ausente | `PrivateKeyNotAvailableError` |
|
||||
| `TOKEN_NOT_FOUND` | Nenhum token USB, smart card ou leitora física detectada | `TokenNotFoundError` |
|
||||
| `TOKEN_REMOVED` | O dispositivo físico foi desconectado durante a operação | `TokenNotFoundError` |
|
||||
| `PIN_REQUIRED` | O hardware criptográfico A3 exige digitação de PIN para desbloqueio | `PinRequiredError` |
|
||||
| `PIN_INCORRECT` | O PIN digitado pelo usuário foi rejeitado pelo token (risco de bloqueio de PUK) | `PinIncorrectError` |
|
||||
| `PKCS11_ERROR` | Falha interna no driver, middleware PKCS#11 ou DLL do fabricante | `CyberCoreError` |
|
||||
| `SIGNATURE_FAILED` | Erro matemático na cifra assimétrica ou payload malformado | `SignatureFailedError` |
|
||||
| `APPLICATION_NOT_AUTHORIZED` | A aplicação web solicitante não possui autorização concedida pelo usuário | `ApplicationNotAuthorizedError` |
|
||||
| `REQUEST_EXPIRED` | O timestamp `expires_at` da requisição foi ultrapassado (timeout) | `RequestExpiredError` |
|
||||
| `REPLAY_DETECTED` | O identificador `nonce` já foi processado anteriormente (ataque de replay) | `ReplayDetectedError` |
|
||||
| `INVALID_ORIGIN` | O cabeçalho `Origin` não confere com os domínios homologados para o AppId | `InvalidOriginError` |
|
||||
| `INVALID_HOST` | O cabeçalho `Host` foi adulterado (tentativa de DNS Rebinding) | `CyberCoreError` |
|
||||
| `UNSUPPORTED_ALGORITHM` | Algoritmo criptográfico solicitado não é suportado pelo provider | `UnsupportedAlgorithmError` |
|
||||
| `USER_CANCELLED` | O usuário clicou explicitamente em 'Cancelar' no diálogo nativo do sistema | `UserCancelledError` |
|
||||
| `PROVIDER_NOT_AVAILABLE` | Provedor criptográfico nativo indisponível na plataforma atual | `ProviderNotAvailableError` |
|
||||
| `COMMUNICATION_ERROR` | Falha na camada de transporte HTTP local do loopback | `CyberCoreError` |
|
||||
| `AGENT_NOT_RUNNING` | O agente desktop não está rodando em 127.0.0.1:21325 (lado SDK) | `AgentNotRunningError` |
|
||||
|
||||
---
|
||||
|
||||
## 5. Especificação JSON Schema e Versionamento
|
||||
|
||||
O contrato canônico e exaustivo de todos os endpoints e payloads do protocolo CCSP/1 está formalizado no arquivo:
|
||||
- [docs/ccsp-v1.schema.json](file:///var/www/cybercore/cybercore-signer/docs/ccsp-v1.schema.json)
|
||||
|
||||
### Política de Versionamento e Retrocompatibilidade
|
||||
|
||||
1. **Identificador de Versão Maior (`version: "1"`):**
|
||||
- O campo `version` em todos os requests e responses identifica a versão maior do protocolo. O agente desktop garante compatibilidade retroativa com todas as revisões do CCSP/1.
|
||||
2. **Evolução Aditiva (Não Quebrante):**
|
||||
- Novos campos opcionais de requisição e novos metadados informativos de resposta podem ser adicionados dentro da versão CCSP/1 sem quebrar clientes existentes. Clientes e servidores devem ignorar propriedades adicionais desconhecidas (`additionalProperties` tolerantes).
|
||||
3. **Mudança Quebrante (Nova Versão Maior):**
|
||||
- Qualquer alteração na semântica de autenticação, remoção de campos obrigatórios ou mudança estrutural de URLs exigirá a introdução de uma nova versão de protocolo (ex: CCSP/2 com prefixo `/ccsp/v2/`). O agente manterá os endpoints `/ccsp/v1/` operacionais em paralelo para garantir transição suave.
|
||||
|
||||
---
|
||||
|
||||
## 6. Diagrama de Sequência Completo
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor User as Usuário
|
||||
participant Browser as Aplicação Web (Browser)
|
||||
participant SDK as @cybercore/signer-sdk
|
||||
participant CCSP as CyberCore CcspServer (127.0.0.1)
|
||||
participant UI as CyberCore Desktop UI
|
||||
participant HW as Hardware Token / Windows MY
|
||||
|
||||
Browser->>SDK: signer.sign({ digest, certId })
|
||||
SDK->>CCSP: POST /ccsp/v1/sign (Headers, AppId, Nonce, Digest)
|
||||
CCSP->>CCSP: Valida Origin, AppId e Nonce (anti-replay)
|
||||
CCSP->>UI: Solicita confirmação visual (requestSignatureConfirmation)
|
||||
UI->>User: Exibe Diálogo Nativo (Aplicação, Documento, Certificado)
|
||||
User->>UI: Clica em "Assinar"
|
||||
opt Certificado A3 exige PIN
|
||||
UI->>User: Solicita PIN do Hardware
|
||||
User->>UI: Digita PIN
|
||||
end
|
||||
UI-->>CCSP: Confirmação concedida
|
||||
CCSP->>HW: signLock.lock() -> JCA/JCE Signature.sign()
|
||||
HW-->>CCSP: Bytes da assinatura + Cadeia X.509
|
||||
CCSP->>CCSP: signLock.unlock()
|
||||
CCSP-->>SDK: 200 OK (signature Base64, cert, subject)
|
||||
SDK-->>Browser: Retorna resultado para a página web
|
||||
```
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# Diagnóstico: Janela Principal no Windows 11 (Bisseção e Monitoramento)
|
||||
|
||||
Este documento descreve o procedimento de diagnóstico para isolar a causa de ocultamento nativo da janela principal no Windows 11 ao passar o mouse.
|
||||
|
||||
---
|
||||
|
||||
## 1. Monitor Nativo Win32/DWM (`scripts/diag-window.ps1`)
|
||||
|
||||
O script [scripts/diag-window.ps1](file:///var/www/cybercore/cybercore-signer/scripts/diag-window.ps1) inspeciona a cada 300ms a janela nativa via P/Invoke da Win32 API e DWM:
|
||||
- `IsWindowVisible`: se a janela está visível perante o subsistema Win32;
|
||||
- `DWMWA_CLOAKED`: se o gerenciador gráfico do Windows 11 ocultou a composição;
|
||||
- `IsIconic`: se foi minimizada;
|
||||
- `GetWindowRect`: coordenadas reais (detecta se foi deslocada para fora da tela).
|
||||
|
||||
### Como executar o monitor:
|
||||
Em uma janela separada do PowerShell:
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\scripts\diag-window.ps1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Roteiro de Bisseção (Fase 2)
|
||||
|
||||
Execute cada um dos comandos abaixo individualmente (garantindo antes que instâncias anteriores foram encerradas com `Stop-Process -Name java -Force` se necessário):
|
||||
|
||||
### Teste A: Desativar Middleware PKCS#11 / SafeSign (`aetpkss1.dll`)
|
||||
Isola hooks nativos do SafeSign e middlewares A3:
|
||||
```powershell
|
||||
java -Dcybercore.diag.no-pkcs11=true -jar .\cybercore-signer.jar --dev
|
||||
```
|
||||
*Se a janela NÃO sumir ao passar o mouse, a causa é o hook nativo do middleware PKCS#11.*
|
||||
|
||||
---
|
||||
|
||||
### Teste B: Plain Stage (Janela livre sem `setResizable(false)` e sem limites min/max)
|
||||
Isola o bug de composição do DWM / Glass no Windows 11 com janelas de tamanho fixo:
|
||||
```powershell
|
||||
java -Dcybercore.diag.plain-stage=true -jar .\cybercore-signer.jar --dev
|
||||
```
|
||||
*Se a janela NÃO sumir ao passar o mouse, a causa é a restrição fixa de tamanho no Windows 11.*
|
||||
|
||||
---
|
||||
|
||||
### Teste C: Desativar Efeitos de Sombra no Mouse (`-fx-effect`)
|
||||
Isola recálculo de dropshadow e troca de cursor:
|
||||
```powershell
|
||||
java -Dcybercore.diag.no-effects=true -jar .\cybercore-signer.jar --dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Teste D: Desativar Monitor Dinâmico de Smart Cards (PC/SC)
|
||||
Isola threads em segundo plano de `javax.smartcardio`:
|
||||
```powershell
|
||||
java -Dcybercore.diag.no-smartcard-watcher=true -jar .\cybercore-signer.jar --dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Teste E: Desativar Windows Certificate Store (Windows-MY)
|
||||
Isola inicialização de CryptoAPI / SunMSCAPI:
|
||||
```powershell
|
||||
java -Dcybercore.diag.no-wincert=true -jar .\cybercore-signer.jar --dev
|
||||
```
|
||||
@@ -0,0 +1,73 @@
|
||||
# Documentação Técnica — CyberCore Signer
|
||||
|
||||
> **Índice Unificado e Guias Técnicos do Agente Desktop e Protocolo CCSP**
|
||||
|
||||
Bem-vindo à documentação oficial do **CyberCore Signer**. O agente foi desenvolvido em Java 25 LTS com interface JavaFX e segurança criptográfica nativa JCA/JCE, permitindo que aplicações web autorizadas realizem operações de assinatura digital e autenticação com certificados ICP-Brasil (A1 em arquivo e A3 em tokens/smartcards físicos) mantendo as chaves privadas estritamente no dispositivo do usuário.
|
||||
|
||||
---
|
||||
|
||||
## 🗺️ Trilhas de Navegação por Perfil
|
||||
|
||||
Para facilitar a consulta, os documentos estão organizados de acordo com o seu perfil de atuação:
|
||||
|
||||
### 🌐 1. Para Desenvolvedores Web e Integradores
|
||||
Se você desenvolve aplicações web (frontend/backend) que precisam interagir com o agente do usuário:
|
||||
|
||||
* **[Protocolo CCSP/1 (CyberCore Signing Protocol)](CCSP.md):** Especificação completa da API REST local (`127.0.0.1:21325`), endpoints, ciclo de handshake, proteção anti-replay e exemplos de requisição/resposta.
|
||||
* **[SDK Web JavaScript (@cybercore/signer-sdk)](sdk.md):** Manual de instalação e uso da biblioteca oficial cliente para integração em aplicações web (Vite, Next.js, Vue, Angular, Vanilla JS).
|
||||
* **[Autenticação e Login Digital ICP-Brasil](authentication-login.md):** Fluxo de autenticação segura via desafio criptográfico (*Challenge-Response*) para substituição ou complemento de senhas tradicionais.
|
||||
* **[Catálogo Estruturado de Erros](errors.md):** Lista completa de códigos de erro (`CcspErrorCode`), status HTTP correspondentes e orientações de tratamento na interface web.
|
||||
|
||||
---
|
||||
|
||||
### ☕ 2. Para Desenvolvedores Desktop e Contribuidores
|
||||
Se você atua no desenvolvimento, manutenção ou auditoria do código-fonte do agente desktop:
|
||||
|
||||
* **[Arquitetura do Sistema](architecture.md):** Organização modular Maven (`cybercore-core`, `cybercore-ui`, `cybercore-app`), fluxo de dados por digest, modelo de concorrência com fila FIFO (`signLock`) e isolamento de dependências.
|
||||
* **[Certificados A1, A3 e PKCS#11](a1-a3-certificates.md):** Funcionamento dos provedores `Pkcs12CertificateProvider`, `WindowsCertificateStoreProvider` (SunMSCAPI/CNG) e `Pkcs11CertificateProvider` (SunPKCS11), monitoramento dinâmico PC/SC (`SmartCardWatcher`) e extração de OIDs ICP-Brasil.
|
||||
* **[Assinaturas PDF (PAdES) e Selos Visuais SVG](pades.md):** Motor de assinatura PDFBox + Bouncy Castle, salvamento incremental, renderização de alta resolução (300 DPI Java2D) e gerenciamento de selos digitais.
|
||||
* **[Assinaturas XML (XAdES-BES e XMLDSig)](xml-xmldsig.md):** Conformidade com o padrão ETSI TS 101 903 / DOC-ICP-15.03 e suporte a notas fiscais eletrônicas (NF-e, CT-e, MDF-e, eSocial).
|
||||
* **[Layout Dual-Theme e Assinatura em Lote](serpro-layout-and-batch.md):** Especificação de interface gráfica moderna, alternância dinâmica de tema (Claro/Escuro) e fila de processamento em lote.
|
||||
* **[Modo de Desenvolvimento](development-mode.md):** Guia de inicialização em modo `--dev`, políticas de não utilização de mocks em produção e geração de certificados A1 de teste locais.
|
||||
|
||||
---
|
||||
|
||||
### 🛡️ 3. Para DevOps, Infraestrutura e Segurança
|
||||
Se você precisa implantar, homologar ou validar os aspectos de conformidade e segurança da solução:
|
||||
|
||||
* **[Modelo de Segurança e Privacidade](security.md):** Diretrizes de menor privilégio, proteção contra CSRF e DNS Rebinding, descarte seguro de senhas e PIN em memória e conformidade com a LGPD.
|
||||
* **[Instalação e Empacotamento Nativo](installation.md):** Criação de instaladores autocontidos para Windows (MSI/EXE) via `jpackage`, requisitos do sistema e parâmetros de instalação corporativa silenciosa.
|
||||
* **[Estratégia de Versionamento e Release](release-and-versioning.md):** Padrão SemVer 2.0.0, plano progressivo de cobertura JaCoCo e roteiro para corte da primeira tag v0.1.0.
|
||||
* **[Diagnóstico e Troubleshooting](diagnostics.md):** Subsistema de auto-teste, checklist de integridade de runtime e guia de resolução de problemas comuns de hardware/middleware.
|
||||
* **[Limitações Técnicas Declaradas](limitations.md):** Declaração formal de requisitos de drivers, limitações de hardware mono-thread e dependências de sistema operacional (em conformidade com a Regra 23 do projeto).
|
||||
|
||||
---
|
||||
|
||||
## 📋 Sumário Geral de Arquivos
|
||||
|
||||
| Documento | Assunto Principal | Público-Alvo |
|
||||
|---|---|---|
|
||||
| [`CCSP.md`](CCSP.md) | Especificação técnica do protocolo local CCSP versão 1 | Integradores Web & Core Devs |
|
||||
| [`architecture.md`](architecture.md) | Estrutura modular, threading e fluxo de operações | Desenvolvedores Desktop |
|
||||
| [`a1-a3-certificates.md`](a1-a3-certificates.md) | Provedores A1/A3, PKCS#11, PC/SC e OIDs ICP-Brasil | Especialistas em Criptografia |
|
||||
| [`pades.md`](pades.md) | Assinatura PAdES, envelope CMS e selos vetoriais | Desenvolvedores Desktop & Web |
|
||||
| [`xml-xmldsig.md`](xml-xmldsig.md) | XAdES-BES, XMLDSig e documentos fiscais | Integradores de NF-e/XAdES |
|
||||
| [`authentication-login.md`](authentication-login.md) | Login Challenge-Response via ICP-Brasil | Integradores Web |
|
||||
| [`errors.md`](errors.md) | Tabela estruturada de erros e mapeamentos HTTP | Todos |
|
||||
| [`security.md`](security.md) | Políticas de isolamento, memória e privacidade | Segurança & Compliance |
|
||||
| [`diagnostics.md`](diagnostics.md) | Auto-testes e guia de resolução de problemas | Suporte & Operações |
|
||||
| [`installation.md`](installation.md) | Empacotamento com `jpackage` e instaladores MSI | DevOps & Usuários Finais |
|
||||
| [`release-and-versioning.md`](release-and-versioning.md) | Estratégia SemVer, metas JaCoCo e corte de tags | DevOps & Governança |
|
||||
| [`compliance-testing.md`](compliance-testing.md) | Guia de Conformidade ICP-Brasil e Verificador ITI | Compliance & QA |
|
||||
| [`compatibility.md`](compatibility.md) | Matriz de compatibilidade de hardware A3 e SoftHSM2 | Suporte & Hardware |
|
||||
| [`limitations.md`](limitations.md) | Dependências de drivers e limitações declaradas | Todos |
|
||||
| [`development-mode.md`](development-mode.md) | Parâmetro `--dev` e certificados de teste | Desenvolvedores Desktop |
|
||||
| [`sdk.md`](sdk.md) | Guia completo da biblioteca JavaScript | Desenvolvedores Web |
|
||||
| [`browser-compatibility.md`](browser-compatibility.md) | Compatibilidade de navegadores, HTTPS para loopback e Private Network Access (PNA) | Integradores Web & Segurança |
|
||||
| [`ccsp-v1.schema.json`](ccsp-v1.schema.json) | JSON Schema formal e versionado do protocolo CCSP/1 | Integradores & QA |
|
||||
| [`sdk-release-process.md`](sdk-release-process.md) | Processo de publicação e release do SDK no registry npm privado | DevOps & Engenharia |
|
||||
| [`serpro-layout-and-batch.md`](serpro-layout-and-batch.md) | Design de interface e processamento em lote | UI/UX & Desenvolvedores |
|
||||
|
||||
---
|
||||
|
||||
> Dúvidas ou inconsistências? Verifique os testes unitários e de arquitetura em `modules/cybercore-core/src/test` ou consulte o arquivo [README.md principal](../README.md).
|
||||
@@ -1,46 +1,100 @@
|
||||
# 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.
|
||||
> **Guia Técnico de Suporte a Provedores Criptográficos, Hardware PC/SC e ICP-Brasil**
|
||||
|
||||
O **CyberCore Signer** fornece uma camada unificada de abstração para gerenciamento e consumo de certificados digitais brasileiros padrão **ICP-Brasil**, cobrindo certificados em arquivo de software (**A1**) e dispositivos físicos criptográficos em hardware (**A3** via tokens USB e cartões inteligentes smartcards).
|
||||
|
||||
---
|
||||
|
||||
## 1. Certificados Digitais A1 (Software / PKCS#12)
|
||||
|
||||
Os certificados A1 são arquivos com extensão `.pfx` ou `.p12` protegidos por senha.
|
||||
Os certificados padrão A1 são contêineres criptográficos protegidos por senha em formato `.pfx` ou `.p12`.
|
||||
|
||||
### 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")`.
|
||||
### 1.1 Arquitetura e Ciclo de Vida
|
||||
1. **Importação:** O usuário seleciona o arquivo via diálogo nativo (`FileChooser`) na interface ou pelo método de importação no Dashboard.
|
||||
2. **Coleta Segura de Senha:** A senha é obtida por meio do diálogo `A1PasswordDialog` e armazenada estritamente em um array de caracteres `char[]` volátil. Logo após o carregamento do `KeyStore`, o array é preenchido com zeros via `Arrays.fill(passwordChars, (char) 0)`.
|
||||
3. **Instanciação JCA/JCE:** O provedor `Pkcs12CertificateProvider` inicializa um `KeyStore.getInstance("PKCS12")` e extrai o certificado X.509 e o manipulador da chave privada (`PrivateKey`).
|
||||
4. **Isolamento de Memória:** A chave privada nunca é serializada, exportada para disco ou enviada em logs. Durante a assinatura, a chave privada em memória gera os bytes da assinatura 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`).
|
||||
No sistema operacional Microsoft Windows (Windows 10 e Windows 11), a grande maioria dos middlewares de fabricantes de tokens e smartcards registra os certificados automaticamente no repositório de certificados pessoais 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.
|
||||
### 2.1 Funcionamento do `WindowsCertificateStoreProvider`
|
||||
* **Integração SunMSCAPI / CNG:** O agente consome o provedor JCA nativo `SunMSCAPI` (`KeyStore.getInstance("Windows-MY")`), integrando-se diretamente à CryptoAPI e à moderna Cryptography Next Generation (CNG) do Windows.
|
||||
* **Preservação no Chip Físico:** A chave privada nunca sai do chip criptográfico do token. As operações de assinatura delegam a cifragem ao módulo de segurança em hardware (HSM/Token).
|
||||
* **Diálogo Nativo de PIN do Fabricante:** Quando o token exige autenticação por PIN para liberar a chave privada, a janela nativa do próprio driver do fabricante (ex: diálogo nativo SafeSign ou SafeNet) é exibida com total segurança pelo subsistema de segurança do Windows.
|
||||
* **Tratamento de Chaves Não Exportáveis:** O CyberCore Signer respeita integralmente a flag de chave privada não exportável, utilizando apenas referências de handle sem tentar copiar os bytes brutos da chave.
|
||||
|
||||
---
|
||||
|
||||
## 3. Hardware PKCS#11 Direto (Tokens USB e Smartcards)
|
||||
## 3. Acesso Direto a Hardware PKCS#11 (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`).
|
||||
Para ambientes corporativos que requerem comunicação direta com bibliotecas de middleware ou para execução em ambientes Linux, o componente `Pkcs11CertificateProvider` interage com o driver PKCS#11 do fabricante através do módulo do JDK `jdk.crypto.cryptoki` (`SunPKCS11`).
|
||||
|
||||
### 3.1 Mapeamento de Middlewares Homologados no Brasil
|
||||
|
||||
O CyberCore Signer possui rotinas automáticas de varredura nos diretórios padrão do sistema (`System32`, `SysWOW64` no Windows e `/usr/lib/`, `/usr/local/lib/` no Linux):
|
||||
|
||||
| Fabricante / Modelo | Biblioteca Windows (DLL) | Biblioteca Linux (.so) | Tipo de Dispositivo |
|
||||
|---|---|---|---|
|
||||
| **SafeSign Identity Client** (AET / GD) | `aetpkss1.dll` | `libaetpkss.so` | Tokens e Smartcards (Certisign, Valid, Serasa) |
|
||||
| **SafeNet Authentication Client** (Gemalto / Thales) | `eps2003csp11.dll`, `eTPKCS11.dll` | `libeTPkcs11.so` | Tokens eToken Pro / 5110 (Serpro, Safeweb) |
|
||||
| **ACOS / Oberthur** (Idemia) | `acospkcs11.dll`, `OcsCryptoki.dll` | `libacospkcs11.so` | Smartcards e leitores de cartão |
|
||||
| **Watchdata / PROXKey** | `Watchdata ProxKey PKCS11.dll` | `libwatchdata.so` | Tokens USB |
|
||||
| **OpenSC (Padrão Aberto)** | `opensc-pkcs11.dll` | `opensc-pkcs11.so` | Leitores genéricos PC/SC e smartcards |
|
||||
|
||||
### 3.2 Configuração Dinâmica do Provedor SunPKCS11
|
||||
A configuração do provedor é criada dinamicamente em memória (sem gravar arquivos temporários em disco com dados sensíveis):
|
||||
|
||||
```properties
|
||||
name = CyberCore-Token
|
||||
library = C:\Windows\System32\aetpkss1.dll
|
||||
slotListIndex = 0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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`.
|
||||
O componente `SmartCardWatcher` executa um loop em segundo plano baseado na API padrão `javax.smartcardio` (subsistema PC/SC):
|
||||
|
||||
1. **Detecção de Conexão (Card Inserted):**
|
||||
- Detecta quando um novo smartcard ou token USB é conectado a qualquer leitora do sistema.
|
||||
- Dispara recarregamento assíncrono do repositório de certificados.
|
||||
- Notifica a interface gráfica (`UIEventListener`) e emite evento de auditoria (`AUDITORIA [TOKEN_INSERTED]`).
|
||||
2. **Detecção de Desconexão (Card Removed):**
|
||||
- Ao detectar a remoção do dispositivo, registra imediatamente o evento de auditoria `AUDITORIA [TOKEN_REMOVED]`.
|
||||
- Limpa e zera os buffers de memória de PIN mantidos pelo `PinSessionManager`.
|
||||
- Força o encerramento das conexões com o leitor via `SmartCardSessionManager` para liberar handles travados no sistema operacional.
|
||||
3. **Serialização de Concorrência (`signLock`):**
|
||||
- Como grande parte dos drivers de smartcard e chips criptográficos físicos não suporta acesso concorrente multi-thread, todas as requisições de assinatura são processadas de forma estritamente sequencial (fila FIFO garantida por `ReentrantLock`).
|
||||
|
||||
---
|
||||
|
||||
## 5. Extração e Parsing de Identidade ICP-Brasil (OIDs X.509)
|
||||
|
||||
O CyberCore Signer implementa o analisador de extensões X.509 conforme os padrões da **Infraestrutura de Chaves Públicas Brasileira (ICP-Brasil)** estabelecidos pelo ITI (normas DOC-ICP-04).
|
||||
|
||||
As informações são extraídas da extensão `SubjectAlternativeName` (OID `2.5.29.17`) e analisadas a partir de seus identificadores de objeto (OIDs):
|
||||
|
||||
### 5.1 OID ICP-Brasil de Pessoa Física (e-CPF) — `2.16.76.1.3.1`
|
||||
Contém uma estrutura ASN.1 com as informações cadastrais do titular:
|
||||
* **Data de Nascimento:** 8 caracteres no formato `DDMMAAAA`;
|
||||
* **CPF do Titular:** 11 dígitos numéricos;
|
||||
* **Número do NIS/PIS/PASEP:** 11 dígitos numéricos;
|
||||
* **Registro Geral (RG):** Número do documento de identidade;
|
||||
* **Órgão Expedidor e UF:** Identificação do órgão emissor.
|
||||
|
||||
### 5.2 OID ICP-Brasil de Pessoa Jurídica (e-CNPJ) — `2.16.76.1.3.3`
|
||||
Contém os dados cadastrais da empresa e do seu representante legal:
|
||||
* **CNPJ da Entidade:** 14 dígitos numéricos;
|
||||
* **Nome do Responsável:** Nome completo do signatário cadastrado na Receita Federal.
|
||||
|
||||
### 5.3 Demais OIDs Mapeados
|
||||
* **OID `2.16.76.1.3.2`:** Título de Eleitor, zona e seção eleitoral.
|
||||
* **OID `2.16.76.1.3.4`:** Cadastro Específico do INSS (CEI / CAEPF).
|
||||
|
||||
Essa extração permite ao agente preencher automaticamente o nome e documento do signatário para o SDK Web e selos visuais em PDF, garantindo conformidade com a legislação e auditoria fiscal.
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
# 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.
|
||||
> **Especificação da Arquitetura Modular, Modelo de Threads e Fluxo Criptográfico**
|
||||
|
||||
O **CyberCore Signer** foi concebido a partir de princípios de alta coesão, baixo acoplamento e estrito isolamento de camadas. O software é desenvolvido em **Java 25 LTS**, utilizando o runtime modular do JDK, JavaFX para a camada de apresentação e os provedores JCA/JCE da plataforma para operações criptográficas seguras.
|
||||
|
||||
---
|
||||
|
||||
## 1. Visão Geral da Arquitetura em Módulos
|
||||
|
||||
O projeto adota uma arquitetura desacoplada em múltiplos módulos Maven independentes:
|
||||
O projeto adota uma divisão modular rigorosa no Maven, garantindo que regras de negócio e algoritmos de criptografia nunca se acoplem a componentes visuais:
|
||||
|
||||
```
|
||||
cybercore-signer/
|
||||
@@ -19,43 +21,75 @@ cybercore-signer/
|
||||
└── 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.1 Módulo `cybercore-core` (Agnóstico de UI)
|
||||
O módulo de núcleo é totalmente independente de JavaFX ou bibliotecas visuais:
|
||||
* **Domínio Imutável:** Modelos imutáveis (`ManagedCertificate`, `SigningContext`, `SigningResult`, `CertificateType`, `CertificateStatus`).
|
||||
* **Serviços de Assinatura:** `SigningService` orquestra a aplicação de algoritmos certificados (`SHA256withRSA`) delegando a operação ao provedor de certificado resolvido.
|
||||
* **Provedores de Certificados (`CertificateProvider`):** Abstração que unifica:
|
||||
- `Pkcs12CertificateProvider`: leitura de arquivos `.pfx`/`.p12` em memória com limpeza volátil de senha.
|
||||
- `WindowsCertificateStoreProvider`: integração com o repositório nativo do Windows (`Windows-MY` via SunMSCAPI/CNG).
|
||||
- `Pkcs11CertificateProvider`: integração direta com bibliotecas dinâmicas (DLL/SO) de drivers e tokens USB através do SunPKCS11.
|
||||
* **Protocolo e Rede Local:** `CcspServer` escuta exclusivamente em `127.0.0.1:21325`, fornecendo a API REST CCSP/1 com validação de origens e controle de concorrência.
|
||||
* **Segurança e Persistência:** `ApplicationRegistry`, `NonceManager` (proteção anti-replay), `OriginValidator`, `SmartCardSessionManager` e `AgentConfigurationService` com armazenamento persistente em diretório de usuário (`~/.cybercore/`).
|
||||
* **Assinatura de Documentos e Validação:** `PdfSigningService` (PAdES com salvamento incremental e carimbo TSA via PDFBox e Bouncy Castle), `XmlSigningService` (XAdES-BES e XMLDSig via `javax.xml.crypto`) e `SignatureValidationService` para conferência de integridade e signatários.
|
||||
|
||||
### 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.2 Módulo `cybercore-ui` (Apresentação JavaFX)
|
||||
Responsável por toda a interação com o usuário:
|
||||
* **Interface Principal (`MainAppView`):** Painel de navegação moderno com alternador dinâmico de tema (Claro/Escuro), grade de operações rápidas (PDF, XML, Assinatura Atachada, Designer de Selos, Validar Assinatura, Sair do Agente) e coluna lateral de utilitários (Logs em tempo real, Configurações, Tutorial, Certificados e Sobre).
|
||||
* **Diálogos de Consentimento e PIN:** Modais nativos de aprovação visual para requisições do protocolo CCSP/1 (`SignatureConfirmationDialog`, `ApplicationAuthorizeDialog`) e coleta segura de PIN de tokens (`A1PasswordDialog`).
|
||||
* **Designer de Selos Digitais (`SealDesignerDialog`):** Ferramenta com Live Preview para criação de selos visuais com controle de cores, estilo de brasão, exibição de nome completo sem truncamento e privacidade de CPF.
|
||||
* **Bandeja do Sistema (System Tray):** Execução minimizada com menu de contexto rápido para restauração da tela ou encerramento da sessão.
|
||||
|
||||
### 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`.
|
||||
### 1.3 Módulo `cybercore-app` (Orquestração e Bootstrap)
|
||||
Ponto de partida da aplicação (`CyberCoreApp`):
|
||||
* Processa argumentos de linha de comando (`--dev`, `--headless`).
|
||||
* Executa rotinas de diagnóstico pré-inicialização do ambiente Java e disponibilidade criptográfica.
|
||||
* Inicia o `CcspServer` em segundo plano e abre a interface gráfica na `JavaFX Application Thread`.
|
||||
* Empacota o executável consolidado via `maven-shade-plugin` e orquestra o build nativo via `jpackage`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Fluxo de Dados e Princípio de Menor Privilégio
|
||||
## 2. Modelo de Threads e Concorrência
|
||||
|
||||
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.
|
||||
Para manter a interface gráfica sempre fluida e ágil, o CyberCore Signer utiliza um particionamento rígido de threads:
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph UI Layer
|
||||
JFX[JavaFX Application Thread]
|
||||
end
|
||||
|
||||
subgraph Local Protocol Layer
|
||||
HTTPPool[CcspServer Worker Pool (FixedThreadPool 8)]
|
||||
end
|
||||
|
||||
subgraph Background Services
|
||||
Watcher[SmartCardWatcher Thread (PC/SC Loop)]
|
||||
AsyncTasks[JavaFX Task / CompletableFuture Workers]
|
||||
end
|
||||
|
||||
subgraph Hardware Critical Section
|
||||
Lock[Trava Serializada FIFO: signLock]
|
||||
Hardware[Token Físico A3 / SmartCard Driver]
|
||||
end
|
||||
|
||||
HTTPPool -->|Solicita Aprovação Visual| JFX
|
||||
JFX -->|Consentimento Concedido| HTTPPool
|
||||
HTTPPool --> Lock
|
||||
AsyncTasks --> Lock
|
||||
Lock --> Hardware
|
||||
```
|
||||
|
||||
1. **JavaFX Application Thread:** Dedicada exclusivamente à renderização da interface e tratamento de eventos visuais. Nenhuma operação criptográfica, leitura de disco ou chamada de rede é executada nesta thread.
|
||||
2. **CcspServer Worker Pool:** Pool de 8 threads daemon dedicadas ao atendimento de requisições HTTP da API REST CCSP/1.
|
||||
3. **SmartCardWatcher Thread:** Thread daemon isolada que executa `cardTerminal.waitForCardPresent()` e `waitForCardAbsent()` para monitorar hardware PC/SC sem onerar a CPU.
|
||||
4. **Trava Serializada FIFO (`signLock`):** Implementada via `ReentrantLock(true)`. Garante que operações de assinatura em cartões inteligentes físicos sejam enfileiradas ordenadamente, prevenindo falhas de hardware e estados corrompidos em drivers de fabricantes mono-thread.
|
||||
|
||||
---
|
||||
|
||||
## 3. Fluxo de Dados e Assinatura Descentralizada por Digest
|
||||
|
||||
A arquitetura do CyberCore Signer prioriza a privacidade e o modelo de **assinatura por digest**:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
@@ -66,14 +100,25 @@ sequenceDiagram
|
||||
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
|
||||
Web->>CCSP: POST /ccsp/v1/sign (digest, appId, certHint, nonce, expires_at)
|
||||
CCSP->>CCSP: Valida autorização, nonce anti-replay e integridade do 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->>Usuario: Exibe Diálogo Nativo (Nome do Documento, Hash SHA-256, Certificado)
|
||||
Usuario->>UI: Clica em "Autorizar Assinatura" (e digita PIN se requerido)
|
||||
UI-->>CCSP: Confirmação concedida
|
||||
CCSP->>Crypto: sign(SigningContext) [Trava FIFO]
|
||||
CCSP->>Crypto: sign(SigningContext) sob signLock.lock()
|
||||
Crypto->>Crypto: JCA/JCE Signature.update(digest) + sign()
|
||||
Crypto-->>CCSP: SigningResult (signatureBytes, certChain)
|
||||
CCSP-->>Web: JSON { status: "signed", signature: "...", certificate: "..." }
|
||||
CCSP-->>Web: 200 OK JSON { status: "signed", signature: "Base64", certificate: "Base64" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Persistência de Dados e Configurações
|
||||
|
||||
Todos os dados de configuração e personalizações do usuário são armazenados no diretório do usuário no sistema operacional:
|
||||
|
||||
* **Diretório Base:** `~/.cybercore/` (ou `%USERPROFILE%\.cybercore\` no Windows).
|
||||
* **`config.json`:** Configurações globais do agente (política de cache de PIN, tema padrão, selo padrão ativo).
|
||||
* **`authorized-apps.json`:** Registro de aplicações web autorizadas pelo usuário, contendo `application_id`, nome amigável e data da aprovação.
|
||||
* **`seals/*.json`:** Selos visuais SVG personalizados criados ou importados pelo usuário no designer de selos.
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
# Compatibilidade de Navegadores e Acesso de Rede Privada (CCSP/1)
|
||||
|
||||
Este documento detalha o funcionamento, as garantias de segurança e os requisitos de compatibilidade entre aplicações web (servidas sob **HTTPS**) e o agente desktop **CyberCore Signer** em loopback local (`http://127.0.0.1:21325`).
|
||||
|
||||
---
|
||||
|
||||
## 1. Contexto Arquitetural: HTTPS para Loopback HTTP
|
||||
|
||||
Tradicionalmente, navegadores modernos impedem o carregamento de recursos inseguros (`http://`) a partir de contextos seguros (`https://`), mecanismo conhecido como **Mixed Content**.
|
||||
|
||||
No entanto, para agentes de assinatura digital locais e periféricos de hardware, o consórcio **W3C (Secure Contexts Specification)** e os motores de renderização modernos tratam o loopback IPv4 (`127.0.0.1`) e `localhost` como **Origens Potencialmente Confiáveis (*Potentially Trustworthy Origins*)**.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant Web as Aplicação Web (HTTPS)
|
||||
participant Browser as Navegador (Chrome/Firefox/Safari)
|
||||
participant Agent as CyberCore Signer (127.0.0.1:21325)
|
||||
|
||||
Note over Web,Agent: 1. Preflight CORS + Private Network Access (PNA)
|
||||
Web->>Browser: fetch("http://127.0.0.1:21325/ccsp/v1/sign", { method: "POST" })
|
||||
Browser->>Agent: OPTIONS /ccsp/v1/sign<br/>Origin: https://meusistema.com.br<br/>Access-Control-Request-Private-Network: true
|
||||
Agent-->>Browser: HTTP 204 No Content<br/>Access-Control-Allow-Origin: *<br/>Access-Control-Allow-Private-Network: true<br/>Access-Control-Allow-Methods: GET, POST, OPTIONS
|
||||
Note over Browser,Agent: 2. Requisição Real CCSP/1
|
||||
Browser->>Agent: POST /ccsp/v1/sign (JSON Digest)
|
||||
Agent-->>Browser: HTTP 200 OK (Assinatura Base64)
|
||||
Browser-->>Web: Retorno Promise Resolvida
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Private Network Access (PNA / W3C)
|
||||
|
||||
O **Private Network Access (PNA)** é uma especificação de segurança do W3C (adotada inicialmente pelo motor Chromium a partir do Chrome 98+) com o objetivo de proteger a rede local contra ataques de Cross-Site Request Forgery (CSRF).
|
||||
|
||||
### Como a especificação classifica as redes:
|
||||
1. **Pública (*public*):** A internet pública (ex: `https://meusistema.com.br`).
|
||||
2. **Privada (*private*):** Redes locais RFC 1918 (ex: `192.168.x.x`, `10.x.x.x`).
|
||||
3. **Local (*local*):** A máquina do próprio usuário (`127.0.0.1`, `localhost`).
|
||||
|
||||
Quando uma página em um espaço de IP mais público solicita um recurso em um espaço mais restrito (como `public` $\rightarrow$ `local`), o navegador dispara uma requisição preliminar **Preflight PNA**.
|
||||
|
||||
### Headers Enviados pelo Navegador:
|
||||
```http
|
||||
OPTIONS /ccsp/v1/sign HTTP/1.1
|
||||
Host: 127.0.0.1:21325
|
||||
Origin: https://meusistema.com.br
|
||||
Access-Control-Request-Method: POST
|
||||
Access-Control-Request-Headers: Content-Type, X-CyberCore-AppId
|
||||
Access-Control-Request-Private-Network: true
|
||||
```
|
||||
|
||||
### Resposta Padrão do CyberCore Signer (`CcspServer`):
|
||||
O servidor do agente desktop intercepta requisições `OPTIONS` e responde imediatamente com:
|
||||
```http
|
||||
HTTP/1.1 204 No Content
|
||||
Access-Control-Allow-Origin: *
|
||||
Access-Control-Allow-Methods: GET, POST, OPTIONS
|
||||
Access-Control-Allow-Headers: Content-Type, Authorization, X-CyberCore-AppId, Origin, Accept
|
||||
Access-Control-Allow-Private-Network: true
|
||||
Access-Control-Max-Age: 86400
|
||||
Connection: close
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> O cabeçalho `Access-Control-Allow-Private-Network: true` é fundamental para autorizar o navegador a enviar requisições privadas originadas de sites HTTPS públicos.
|
||||
|
||||
---
|
||||
|
||||
## 3. Matriz de Compatibilidade de Navegadores
|
||||
|
||||
| Navegador | Versão Mínima | Suporte HTTPS $\rightarrow$ 127.0.0.1 | Comportamento PNA | Requer Ação do Usuário? |
|
||||
| :--- | :---: | :---: | :---: | :---: |
|
||||
| **Google Chrome** | 88+ | ✅ Suportado | PNA ativo com preflight | Não (automático) |
|
||||
| **Microsoft Edge** | 88+ | ✅ Suportado | PNA ativo com preflight | Não (automático) |
|
||||
| **Mozilla Firefox** | 84+ | ✅ Suportado | Trata 127.0.0.1 como Secure Context | Não (automático) |
|
||||
| **Opera / Brave** | Recentes | ✅ Suportado | Idêntico ao Chromium (ver nota Brave) | Pode exigir ajuste no Shields |
|
||||
| **Apple Safari** | 15.4+ (macOS) | ✅ Suportado | Trata loopback como seguro | Não |
|
||||
|
||||
---
|
||||
|
||||
## 4. Casos de Borda e Soluções (Troubleshooting)
|
||||
|
||||
### 4.1. Navegador Brave (Brave Shields)
|
||||
O Brave possui um mecanismo agressivo de proteção que pode bloquear preflights para `127.0.0.1`.
|
||||
- **Sintoma:** O SDK lança `AgentNotRunningError`, mesmo com o agente em execução.
|
||||
- **Resolução:** O usuário deve clicar no ícone do escudo (Shields) na barra de endereços da aplicação e desativar temporariamente o bloqueio para a origem ou permitir conexões locais nas configurações (`brave://settings/shields`).
|
||||
|
||||
### 4.2. Extensões de Privacidade e AdBlockers
|
||||
Extensões como *uBlock Origin*, *Privacy Badger* ou *NoScript* podem conter regras genéricas de bloqueio para portas locais.
|
||||
- **Resolução:** Adicionar a URL da sua aplicação web à lista de permissões (*allowlist*) da extensão.
|
||||
|
||||
### 4.3. Softwares de Antivírus e Endpoint Protection
|
||||
Alguns antivírus com módulo de "Proteção Web" ou "Inspeção HTTPS" tentam atuar como proxy transparente no loopback local:
|
||||
- **Resolução:** Configurar o antivírus para excluir a porta `21325` da inspeção de tráfego local.
|
||||
|
||||
### 4.4. Políticas Corporativas de Grupo (GPO / Chromium Enterprise)
|
||||
Em ambientes corporativos restritos, administradores de rede podem bloquear chamadas a redes privadas via política:
|
||||
- Política Chromium: `InsecurePrivateNetworkRequestsAllowed` ou `InsecurePrivateNetworkRequestsAllowedForUrls`.
|
||||
- **Resolução:** Adicionar a URL do portal web corporativo na política `InsecurePrivateNetworkRequestsAllowedForUrls`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Boas Práticas para Desenvolvedores Web
|
||||
|
||||
1. **Use sempre o SDK `@cybercore/signer-sdk`:** Ele gerencia timeouts, fallbacks de preflight e detecção de conectividade.
|
||||
2. **Utilize `isInstalled()` antes de iniciar fluxos de assinatura:**
|
||||
```javascript
|
||||
import { CyberCoreSigner } from '@cybercore/signer-sdk';
|
||||
|
||||
const signer = new CyberCoreSigner();
|
||||
|
||||
if (!(await signer.isInstalled())) {
|
||||
mostrarModalInstalacaoAgente();
|
||||
}
|
||||
```
|
||||
3. **Capture `AgentNotRunningError` e exiba o guia de ajuda:**
|
||||
```javascript
|
||||
import { AgentNotRunningError } from '@cybercore/signer-sdk';
|
||||
|
||||
try {
|
||||
await signer.connect();
|
||||
} catch (err) {
|
||||
if (err instanceof AgentNotRunningError) {
|
||||
console.info(err.actionableGuidance);
|
||||
exibirAjudaAoUsuario(err.actionableGuidance);
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,417 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "https://cybercore.com.br/schemas/ccsp-v1.json",
|
||||
"title": "CyberCore Signing Protocol v1 (CCSP/1) Specification",
|
||||
"description": "Especificação formal do protocolo JSON para comunicação local entre aplicações web e o agente desktop CyberCore Signer.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"version": {
|
||||
"type": "string",
|
||||
"enum": ["1"],
|
||||
"description": "Versão do protocolo CCSP (sempre '1' para CCSP/1)."
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"ErrorCode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"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",
|
||||
"INVALID_HOST",
|
||||
"UNSUPPORTED_ALGORITHM",
|
||||
"USER_CANCELLED",
|
||||
"PROVIDER_NOT_AVAILABLE",
|
||||
"COMMUNICATION_ERROR",
|
||||
"AGENT_NOT_RUNNING",
|
||||
"TIMEOUT_ERROR",
|
||||
"BAD_REQUEST",
|
||||
"METHOD_NOT_ALLOWED"
|
||||
],
|
||||
"description": "Códigos formais de erro estruturados do protocolo CCSP/1."
|
||||
},
|
||||
|
||||
"ErrorResponse": {
|
||||
"type": "object",
|
||||
"required": ["version", "status", "error_code", "message"],
|
||||
"properties": {
|
||||
"version": { "type": "string", "enum": ["1"] },
|
||||
"status": { "type": "string", "enum": ["error"] },
|
||||
"error_code": { "$ref": "#/definitions/ErrorCode" },
|
||||
"message": { "type": "string" },
|
||||
"request_id": { "type": ["string", "null"] }
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
|
||||
"StatusResponse": {
|
||||
"type": "object",
|
||||
"required": ["version", "status", "agent", "agent_version", "os", "timestamp"],
|
||||
"properties": {
|
||||
"version": { "type": "string", "enum": ["1"] },
|
||||
"status": { "type": "string", "enum": ["ready", "busy", "error"] },
|
||||
"agent": { "type": "string" },
|
||||
"agent_version": { "type": "string" },
|
||||
"os": { "type": "string" },
|
||||
"timestamp": { "type": "string" }
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
|
||||
"AuthorizeRequest": {
|
||||
"type": "object",
|
||||
"required": ["version", "application_id"],
|
||||
"properties": {
|
||||
"version": { "type": "string", "enum": ["1"] },
|
||||
"application_id": { "type": "string", "pattern": "^[0-9a-fA-F-]{36}$" },
|
||||
"name": { "type": "string" }
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
|
||||
"AuthorizeResponse": {
|
||||
"type": "object",
|
||||
"required": ["version", "status", "application_id"],
|
||||
"properties": {
|
||||
"version": { "type": "string", "enum": ["1"] },
|
||||
"status": { "type": "string", "enum": ["authorized"] },
|
||||
"application_id": { "type": "string" },
|
||||
"message": { "type": "string" }
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
|
||||
"CertificateItem": {
|
||||
"type": "object",
|
||||
"required": ["id", "alias", "type", "can_sign", "status"],
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"alias": { "type": "string" },
|
||||
"common_name": { "type": ["string", "null"] },
|
||||
"subject_dn": { "type": ["string", "null"] },
|
||||
"issuer_dn": { "type": ["string", "null"] },
|
||||
"type": { "type": "string", "enum": ["A1", "A3"] },
|
||||
"provider_id": { "type": ["string", "null"] },
|
||||
"device_name": { "type": ["string", "null"] },
|
||||
"valid_from": { "type": ["string", "null"] },
|
||||
"valid_to": { "type": ["string", "null"] },
|
||||
"serial_number": { "type": ["string", "null"] },
|
||||
"cpf": { "type": ["string", "null"] },
|
||||
"cnpj": { "type": ["string", "null"] },
|
||||
"can_sign": { "type": "boolean" },
|
||||
"status": { "type": "string" }
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
|
||||
"CertificatesResponse": {
|
||||
"type": "object",
|
||||
"required": ["version", "status", "certificates"],
|
||||
"properties": {
|
||||
"version": { "type": "string", "enum": ["1"] },
|
||||
"status": { "type": "string", "enum": ["success"] },
|
||||
"certificates": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/definitions/CertificateItem" }
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
|
||||
"SealThemeItem": {
|
||||
"type": "object",
|
||||
"required": ["id", "name"],
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"name": { "type": "string" },
|
||||
"description": { "type": ["string", "null"] },
|
||||
"width": { "type": "number" },
|
||||
"height": { "type": "number" },
|
||||
"primary_color": { "type": ["string", "null"] },
|
||||
"secondary_color": { "type": ["string", "null"] },
|
||||
"background_color": { "type": ["string", "null"] },
|
||||
"border_color": { "type": ["string", "null"] },
|
||||
"text_color": { "type": ["string", "null"] },
|
||||
"icon_style": { "type": ["string", "null"] },
|
||||
"custom": { "type": "boolean" }
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
|
||||
"SealsResponse": {
|
||||
"type": "object",
|
||||
"required": ["version", "status", "seals"],
|
||||
"properties": {
|
||||
"version": { "type": "string", "enum": ["1"] },
|
||||
"status": { "type": "string", "enum": ["success"] },
|
||||
"default_seal_id": { "type": ["string", "null"] },
|
||||
"seals": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/definitions/SealThemeItem" }
|
||||
},
|
||||
"themes": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/definitions/SealThemeItem" }
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
|
||||
"SignRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"version",
|
||||
"request_id",
|
||||
"application_id",
|
||||
"operation",
|
||||
"algorithm",
|
||||
"digest",
|
||||
"nonce",
|
||||
"expires_at"
|
||||
],
|
||||
"properties": {
|
||||
"version": { "type": "string", "enum": ["1"] },
|
||||
"request_id": { "type": "string" },
|
||||
"application_id": { "type": "string" },
|
||||
"operation": { "type": "string", "enum": ["sign", "authenticate"] },
|
||||
"algorithm": { "type": "string" },
|
||||
"digest": { "type": "string", "description": "Digest SHA-256 em Base64" },
|
||||
"certificate_hint": { "type": ["string", "null"] },
|
||||
"document_name": { "type": ["string", "null"] },
|
||||
"document_type": { "type": ["string", "null"] },
|
||||
"purpose": { "type": ["string", "null"] },
|
||||
"challenge": { "type": ["string", "null"] },
|
||||
"nonce": { "type": "string" },
|
||||
"expires_at": { "type": "string" }
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
|
||||
"SignResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"version",
|
||||
"request_id",
|
||||
"status",
|
||||
"signature",
|
||||
"certificate",
|
||||
"algorithm"
|
||||
],
|
||||
"properties": {
|
||||
"version": { "type": "string", "enum": ["1"] },
|
||||
"request_id": { "type": "string" },
|
||||
"status": { "type": "string", "enum": ["signed", "authenticated"] },
|
||||
"signature": { "type": "string", "description": "Assinatura digital em Base64" },
|
||||
"certificate": { "type": "string", "description": "Certificado X.509 do signatário em Base64" },
|
||||
"certificate_chain": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"algorithm": { "type": "string" },
|
||||
"sha256": { "type": ["string", "null"] },
|
||||
"subject_name": { "type": ["string", "null"] },
|
||||
"subject_cpf_cnpj": { "type": ["string", "null"] },
|
||||
"document_name": { "type": ["string", "null"] },
|
||||
"document_type": { "type": ["string", "null"] },
|
||||
"challenge": { "type": ["string", "null"] }
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
|
||||
"SignPdfRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"version",
|
||||
"request_id",
|
||||
"application_id",
|
||||
"operation",
|
||||
"pdf",
|
||||
"nonce",
|
||||
"expires_at"
|
||||
],
|
||||
"properties": {
|
||||
"version": { "type": "string", "enum": ["1"] },
|
||||
"request_id": { "type": "string" },
|
||||
"application_id": { "type": "string" },
|
||||
"operation": { "type": "string", "enum": ["sign_pdf"] },
|
||||
"pdf": { "type": "string", "description": "Documento PDF completo codificado em Base64" },
|
||||
"certificate_hint": { "type": ["string", "null"] },
|
||||
"document_name": { "type": ["string", "null"] },
|
||||
"page": { "type": ["integer", "null"] },
|
||||
"page_choice": { "type": ["string", "null"] },
|
||||
"position": { "type": ["string", "null"] },
|
||||
"x": { "type": ["number", "null"] },
|
||||
"y": { "type": ["number", "null"] },
|
||||
"scale": { "type": ["number", "null"] },
|
||||
"visible_seal": { "type": ["boolean", "null"] },
|
||||
"seal_theme_id": { "type": ["string", "null"] },
|
||||
"seal_custom": { "type": ["object", "null"] },
|
||||
"reason": { "type": ["string", "null"] },
|
||||
"location": { "type": ["string", "null"] },
|
||||
"cpf_display_mode": { "type": ["string", "null"] },
|
||||
"include_date_time": { "type": ["boolean", "null"] },
|
||||
"custom_extra_info": { "type": ["string", "null"] },
|
||||
"nonce": { "type": "string" },
|
||||
"expires_at": { "type": "string" }
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
|
||||
"SignPdfResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"version",
|
||||
"request_id",
|
||||
"status",
|
||||
"signed_pdf",
|
||||
"signature",
|
||||
"certificate",
|
||||
"algorithm"
|
||||
],
|
||||
"properties": {
|
||||
"version": { "type": "string", "enum": ["1"] },
|
||||
"request_id": { "type": "string" },
|
||||
"status": { "type": "string", "enum": ["signed"] },
|
||||
"signed_pdf": { "type": "string", "description": "PDF assinado completo com envelope PAdES em Base64" },
|
||||
"sha256": { "type": ["string", "null"] },
|
||||
"signature": { "type": "string" },
|
||||
"certificate": { "type": "string" },
|
||||
"certificate_chain": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"algorithm": { "type": "string" },
|
||||
"subject_name": { "type": ["string", "null"] },
|
||||
"subject_cpf_cnpj": { "type": ["string", "null"] },
|
||||
"document_name": { "type": ["string", "null"] }
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
|
||||
"SignXmlRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"version",
|
||||
"request_id",
|
||||
"application_id",
|
||||
"operation",
|
||||
"nonce",
|
||||
"expires_at"
|
||||
],
|
||||
"properties": {
|
||||
"version": { "type": "string", "enum": ["1"] },
|
||||
"request_id": { "type": "string" },
|
||||
"application_id": { "type": "string" },
|
||||
"operation": { "type": "string", "enum": ["sign_xml"] },
|
||||
"xml": { "type": ["string", "null"], "description": "XML em texto puro UTF-8" },
|
||||
"xml_base64": { "type": ["string", "null"], "description": "XML codificado em Base64" },
|
||||
"certificate_hint": { "type": ["string", "null"] },
|
||||
"document_name": { "type": ["string", "null"] },
|
||||
"profile": { "type": ["string", "null"], "enum": ["XADES_BES", "XMLDSIG", null] },
|
||||
"target_element_tag": { "type": ["string", "null"] },
|
||||
"target_element_id": { "type": ["string", "null"] },
|
||||
"algorithm": { "type": ["string", "null"] },
|
||||
"nonce": { "type": "string" },
|
||||
"expires_at": { "type": "string" }
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
|
||||
"SignXmlResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"version",
|
||||
"request_id",
|
||||
"status",
|
||||
"signed_xml",
|
||||
"signature",
|
||||
"certificate",
|
||||
"algorithm"
|
||||
],
|
||||
"properties": {
|
||||
"version": { "type": "string", "enum": ["1"] },
|
||||
"request_id": { "type": "string" },
|
||||
"status": { "type": "string", "enum": ["signed"] },
|
||||
"signed_xml": { "type": "string", "description": "Documento XML assinado completo com assinatura enveloped" },
|
||||
"sha256": { "type": ["string", "null"] },
|
||||
"signature": { "type": "string" },
|
||||
"certificate": { "type": "string" },
|
||||
"certificate_chain": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"algorithm": { "type": "string" },
|
||||
"subject_name": { "type": ["string", "null"] },
|
||||
"subject_cpf_cnpj": { "type": ["string", "null"] },
|
||||
"document_name": { "type": ["string", "null"] }
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
|
||||
"AuthenticateRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"version",
|
||||
"request_id",
|
||||
"application_id",
|
||||
"operation",
|
||||
"challenge",
|
||||
"nonce",
|
||||
"expires_at"
|
||||
],
|
||||
"properties": {
|
||||
"version": { "type": "string", "enum": ["1"] },
|
||||
"request_id": { "type": "string" },
|
||||
"application_id": { "type": "string" },
|
||||
"operation": { "type": "string", "enum": ["authenticate"] },
|
||||
"algorithm": { "type": "string" },
|
||||
"digest": { "type": ["string", "null"] },
|
||||
"challenge": { "type": "string" },
|
||||
"purpose": { "type": ["string", "null"] },
|
||||
"document_type": { "type": ["string", "null"] },
|
||||
"certificate_hint": { "type": ["string", "null"] },
|
||||
"nonce": { "type": "string" },
|
||||
"expires_at": { "type": "string" }
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
|
||||
"AuthenticateResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"version",
|
||||
"request_id",
|
||||
"status",
|
||||
"signature",
|
||||
"certificate",
|
||||
"algorithm"
|
||||
],
|
||||
"properties": {
|
||||
"version": { "type": "string", "enum": ["1"] },
|
||||
"request_id": { "type": "string" },
|
||||
"status": { "type": "string", "enum": ["authenticated", "signed"] },
|
||||
"signature": { "type": "string" },
|
||||
"certificate": { "type": "string" },
|
||||
"certificate_chain": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"algorithm": { "type": "string" },
|
||||
"subject_name": { "type": ["string", "null"] },
|
||||
"subject_cpf_cnpj": { "type": ["string", "null"] }
|
||||
},
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
# Matriz de Compatibilidade de Hardware e Dispositivos Criptográficos A3
|
||||
|
||||
Este documento apresenta a matriz técnica de compatibilidade de tokens USB, smartcards e leitores criptográficos suportados pelo **CyberCore Signer**, detalhando drivers, middlewares e o status de homologação de cada dispositivo.
|
||||
|
||||
---
|
||||
|
||||
## 1. Princípio de Transparência e Rastreabilidade
|
||||
|
||||
Conforme as diretrizes mestras do projeto:
|
||||
> *Testes dependentes de hardware só podem ser declarados executados quando o hardware realmente estiver disponível no ambiente de teste. Nunca invente suporte a dispositivos ou fabricantes que não tenham sido realmente testados.*
|
||||
|
||||
O CyberCore Signer utiliza duas camadas complementares para acesso a dispositivos físicos no Windows e Linux:
|
||||
1. **Windows Certificate Store (CAPI / CNG):** Acesso transparente a qualquer token ou smartcard cujo middleware instale um CSP (Cryptographic Service Provider) ou KSP (Key Storage Provider) no Windows.
|
||||
2. **SunPKCS11 (`jdk.crypto.cryptoki`):** Comunicação direta via padrão PKCS#11 (bibliotecas `.dll` no Windows ou `.so` no Linux), garantindo independência de fabricantes.
|
||||
3. **CI Automatizado via SoftHSM2:** O pipeline de integração contínua (CI) valida a pilha criptográfica PKCS#11 utilizando o emulador de hardware criptográfico **SoftHSM2**, testando ciclos completos de inicialização de token, busca de chaves e assinatura matemática SHA-256 com chave RSA sem depender de conexões físicas de hardware no servidor de build.
|
||||
|
||||
---
|
||||
|
||||
## 2. Matriz de Compatibilidade de Tokens e Smartcards
|
||||
|
||||
| Fabricante / Modelo | Tipo de Dispositivo | Middleware / Driver | Biblioteca PKCS#11 | Status de Homologação |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| **SoftHSM2 (Emulador PKCS#11)** | Emulador de Hardware Criptográfico | SoftHSM2 v2.6+ | `libsofthsm2.so` / `softhsm2.dll` | **Validado no CI Automatizado (Gitea)** |
|
||||
| **Thales / SafeNet eToken 5110** | Token USB Criptográfico | SafeNet Authentication Client (SAC) | `eps2003csp11.dll` | Compatível via Windows Store / PKCS#11 (Pendente hardware físico) |
|
||||
| **AET / GD StarSign (SafeSign)** | Smartcard / Token USB | SafeSign Identity Client | `aetpkss1.dll` / `libaetpkss.so` | Compatível via Windows Store / PKCS#11 (Pendente hardware físico) |
|
||||
| **Gemalto IDPrime (MD 830 / 840)** | Token USB / Smartcard | IDPrime Minidriver / Gemalto CC | `gclib.dll` / `libgclib.so` | Compatível nativamente via Windows CNG Minidriver |
|
||||
| **Oberthur / Idemia Cosmo** | Smartcard ICP-Brasil | AWPS / ACOS PKCS#11 | `acospkcs11.dll` / `libacospkcs11.so` | Compatível via Windows Store / PKCS#11 (Pendente hardware físico) |
|
||||
| **Watchdata PROXKey** | Token USB Criptográfico | Watchdata Middleware | `Watchdata ProxKey PKCS11.dll` | Compatível via Windows Store / PKCS#11 (Pendente hardware físico) |
|
||||
| **OpenSC (Padrão Aberto)** | Leitores PC/SC genéricos e Smartcards | OpenSC Suite | `opensc-pkcs11.dll` / `opensc-pkcs11.so` | Compatível via PKCS#11 (Linux e Windows) |
|
||||
|
||||
---
|
||||
|
||||
## 3. Gestão de Concorrência e Conflito de Hardware (`signLock`)
|
||||
|
||||
Grande parte dos microcontroladores de smartcards e tokens USB físicos (chips ISO 7816) **não possui suporte a canais concorrentes multi-thread**. Se duas requisições de assinatura tentarem acessar o chip simultaneamente através do middleware:
|
||||
- O dispositivo trava com erro `CKR_DEVICE_ERROR` ou `SCARD_E_SHARING_VIOLATION`.
|
||||
- O leitor pode exigir reinicialização do serviço PC/SC (`scardsvr`) ou desplugue físico do token.
|
||||
|
||||
### Solução Arquitetural do CyberCore Signer
|
||||
1. **Fila Serializada (FIFO) via `ReentrantLock`:** Todas as operações criptográficas de assinatura no subsistema A3 passam obrigatoriamente pela trava `signLock`, enfileirando requisições concorrentes transparentemente.
|
||||
2. **Sessão PC/SC Desacoplada (`SmartCardWatcher`):** O monitoramento de inserção e remoção utiliza `javax.smartcardio` de forma assíncrona, notificando a interface gráfica sem bloquear o canal criptográfico do SunPKCS11.
|
||||
3. **Limpeza Imediata de Memória:** Ao desconectar o dispositivo, o evento `TOKEN_REMOVED` força a limpeza imediata de buffers e o encerramento das conexões com o leitor (`SmartCardSessionManager`).
|
||||
|
||||
---
|
||||
|
||||
## 4. Requisitos de Instalação no Cliente
|
||||
|
||||
1. **Windows 10 / 11 (64-bit):**
|
||||
- O aplicativo desktop já embute seu próprio runtime Java (OpenJDK 25 LTS via `jpackage`), sem exigir Java instalado na máquina do usuário.
|
||||
- O usuário deve manter instalado o software/driver fornecido pela Autoridade Certificadora emissora do token (ex: SafeNet Authentication Client ou SafeSign).
|
||||
2. **Linux (Ubuntu / Debian / Fedora):**
|
||||
- O serviço `pcscd` deve estar em execução (`sudo systemctl enable --now pcscd`).
|
||||
- Biblioteca de middleware do fabricante instalada em `/usr/lib/` ou `/usr/lib/x86_64-linux-gnu/`.
|
||||
@@ -0,0 +1,65 @@
|
||||
# Guia de Testes de Conformidade ICP-Brasil e Validação Oficial no ITI
|
||||
|
||||
> **Procedimentos de Verificação Criptográfica Automatizada e Manual para Assinaturas PAdES e XAdES-BES**
|
||||
|
||||
---
|
||||
|
||||
## 1. Visão Geral
|
||||
|
||||
O **CyberCore Signer** implementa assinaturas digitais em conformidade com as normas técnicas da **Infraestrutura de Chaves Públicas Brasileira (ICP-Brasil)**, especificamente:
|
||||
|
||||
- **PDF (PAdES):** Padrão ISO 32000-1 e ETSI TS 102 778, com envelope CMS desacoplado (`/adbe.pkcs7.detached`) e algoritmo `SHA256withRSA` (alinhado ao perfil AD-RB).
|
||||
- **XML (XAdES-BES / XMLDSig):** Padrão ETSI TS 101 903 e norma **DOC-ICP-15.03**, contendo propriedades qualificadas assinadas (`<xades:QualifyingProperties>`, `<xades:SigningTime>` e `<xades:SigningCertificate>`).
|
||||
|
||||
---
|
||||
|
||||
## 2. Suíte de Testes Automatizados (`ComplianceSignatureSuiteTest`)
|
||||
|
||||
A conformidade técnica do código é garantida de forma contínua pelo teste automatizado:
|
||||
`modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/compliance/ComplianceSignatureSuiteTest.java`
|
||||
|
||||
### O que o teste automatizado valida:
|
||||
1. **Cadeia ICP-Brasil Simulada em Memória:**
|
||||
- Cria uma hierarquia criptográfica completa (AC Raiz de Teste -> AC Intermediária -> Certificado Signatário com extensão OID e-CPF `2.16.76.1.3.1`).
|
||||
- Garante que nenhum certificado real ou dado pessoal seja exposto em testes.
|
||||
2. **PAdES (PDF):**
|
||||
- Criação de documento PDF original.
|
||||
- Aplicação de selo visual conforme as coordenadas calculadas.
|
||||
- Verificação da integridade do `/ByteRange` cobrindo o PDF integral exceto os bytes de `/Contents`.
|
||||
- Validação criptográfica independente via Bouncy Castle `CMSSignedData.verify()`.
|
||||
- Comparação do digest SHA-256 do documento contra o atributo assinado `messageDigest`.
|
||||
3. **XAdES-BES (XML):**
|
||||
- Assinatura de documento XML com geração de identificadores de referência (`Reference URI`).
|
||||
- Validação independente via motor nativo Java JSR-105 (`javax.xml.crypto.dsig.XMLSignature`).
|
||||
- Teste de inviolabilidade (Tamper Test): verificação de que qualquer modificação de um caractere nos dados de negócio invalida sumariamente o `coreValidity`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Validação Manual no Verificador de Conformidade do ITI
|
||||
|
||||
O **Instituto Nacional de Tecnologia da Informação (ITI)** disponibiliza a ferramenta governamental oficial para validação de assinaturas digitais brasileiras:
|
||||
|
||||
👉 **URL Oficial:** [https://verificador.iti.gov.br](https://verificador.iti.gov.br)
|
||||
|
||||
### 3.1 Procedimento Passo a Passo para Teste Manual
|
||||
|
||||
1. **Gerar ou Exportar o Documento Assinado:**
|
||||
- Através da interface gráfica do CyberCore Signer, assine um documento PDF ou XML utilizando seu certificado digital A1 ou A3.
|
||||
- O arquivo assinado resultante será gravado em disco (ex: `contrato_assinado.pdf` ou `documento_assinado.xml`).
|
||||
|
||||
2. **Submissão no Verificador do ITI:**
|
||||
- Acesse [https://verificador.iti.gov.br](https://verificador.iti.gov.br) em qualquer navegador web moderno.
|
||||
- Clique em **"Selecionar assinatura"** e faça o upload do arquivo gerado pelo CyberCore Signer.
|
||||
- Clique no botão **"Verificar conformidade"**.
|
||||
|
||||
3. **Interpretação do Relatório de Conformidade:**
|
||||
|
||||
#### Cenário A: Assinatura com Certificado de Produção ICP-Brasil (Certisign, Serasa, Serpro, Valid, etc.)
|
||||
- **Status Geral:** `APROVADO` / `CONFORME`.
|
||||
- **Validação Criptográfica:** Íntegra (o hash bate e a assinatura RSA é autêntica).
|
||||
- **Cadeia de Certificação:** Confiável (ancorada até a AC Raiz da ICP-Brasil oficial).
|
||||
- **Atributos do Signatário:** Nome completo e CPF/CNPJ extraídos e validados na Receita Federal.
|
||||
|
||||
#### Cenário B: Assinatura com Certificados de Teste / Desenvolvimento (Gerados na Suíte)
|
||||
- **Status Criptográfico:** `VÁLIDO` (integridade matemática e estrutura PAdES/XAdES aprovadas).
|
||||
- **Alerta de Cadeia:** O verificador do ITI reportará: *"Cadeia de Certificação Não Confiável / AC não credenciada na ICP-Brasil"*. Este é o comportamento **correto e esperado**, uma vez que certificados gerados em testes locais não pertencem à lista de certificados confiáveis (LCR oficial) publicada pelo ITI.
|
||||
@@ -1,48 +1,89 @@
|
||||
# Modo de Desenvolvimento e Diagnóstico Detalhado
|
||||
# Modo de Desenvolvimento e Homologação
|
||||
|
||||
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**.
|
||||
> **Diretrizes para Execução Local com Argumento `--dev`, Testes e Criação de Certificados A1 de Homologação**
|
||||
|
||||
O **CyberCore Signer** opera exclusivamente com certificados digitais e provedores criptográficos reais (arquivos `.pfx`/`.p12`, Windows-MY e tokens físicos PKCS#11). Em cumprimento às normas de integridade do projeto, **não existem mocks ou certificados artificiais no runtime de produção**.
|
||||
|
||||
---
|
||||
|
||||
## 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`:
|
||||
Para iniciar o agente em ambiente de homologação e desenvolvimento com logs estendidos de auditoria e detalhamento de operações de rede, utilize o argumento de linha de comando `--dev`:
|
||||
|
||||
```bash
|
||||
java -jar cybercore-signer.jar --dev
|
||||
# Executando via script Linux / macOS:
|
||||
./run.sh --dev
|
||||
|
||||
# Executando no Windows:
|
||||
run.bat --dev
|
||||
|
||||
# Ou diretamente pelo JAR compilado:
|
||||
java -jar modules/cybercore-app/target/cybercore-signer.jar --dev
|
||||
```
|
||||
|
||||
Ao iniciar, uma mensagem informativa é registrada no log:
|
||||
Ao ser iniciado com esta flag, o agente imprime no terminal o banner de diagnóstico:
|
||||
```
|
||||
=========================================================
|
||||
MODO DE DESENVOLVIMENTO ATIVADO (--dev)
|
||||
Logs detalhados ativos. Operação com certificados reais.
|
||||
CYBERCORE SIGNER — MODO DE DESENVOLVIMENTO ATIVADO (--dev)
|
||||
Logs detalhados ativos. Operação com certificados reais JCA/JCE.
|
||||
Servidor local CCSP/1 pronto em: http://127.0.0.1:21325
|
||||
=========================================================
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Testando em Ambiente de Desenvolvimento
|
||||
## 2. Geração de Certificados A1 de Teste com OpenSSL
|
||||
|
||||
Para desenvolvedores testarem fluxos completos de assinatura de PDF, XML, arquivos em geral e login digital:
|
||||
Para desenvolvedores e testadores que precisam simular fluxos completos de assinatura digital (PAdES, XAdES, login digital) sem utilizar um certificado ICP-Brasil real de produção, é possível gerar um certificado digital de teste no formato `.pfx` protegido por senha utilizando o utilitário **OpenSSL**:
|
||||
|
||||
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.
|
||||
### Passo 1: Gerar a Chave Privada RSA (2048 bits)
|
||||
```bash
|
||||
openssl genrsa -out teste_signer.key 2048
|
||||
```
|
||||
|
||||
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.
|
||||
### Passo 2: Gerar o Certificado X.509 Autoassinado
|
||||
```bash
|
||||
openssl req -new -x509 -key teste_signer.key -out teste_signer.crt -days 365 \
|
||||
-subj "/C=BR/O=ICP-Brasil Teste/OU=Desenvolvimento CyberCore/CN=DESENVOLVEDOR TESTE:12345678900"
|
||||
```
|
||||
|
||||
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.
|
||||
### Passo 3: Exportar o Certificado e Chave no Contêiner PKCS#12 (`.pfx`)
|
||||
```bash
|
||||
openssl pkcs12 -export -out certificado_teste.pfx \
|
||||
-inkey teste_signer.key -in teste_signer.crt \
|
||||
-name "DESENVOLVEDOR TESTE:12345678900" \
|
||||
-passout pass:123456
|
||||
```
|
||||
|
||||
### Passo 4: Importar no CyberCore Signer
|
||||
1. Abra o CyberCore Signer.
|
||||
2. Na tela principal, clique em **"Importar A1 (.pfx/.p12)"**.
|
||||
3. Selecione o arquivo `certificado_teste.pfx` gerado e informe a senha `123456`.
|
||||
4. O certificado aparecerá instantaneamente como disponível no Dashboard e poderá ser utilizado para todos os testes do SDK e CCSP/1.
|
||||
|
||||
---
|
||||
|
||||
## 3. Garantia de Operação Real
|
||||
## 3. Execução da Suíte de Testes Automatizados
|
||||
|
||||
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.
|
||||
O repositório do CyberCore Signer conta com suíte abrangente de testes unitários, testes de concorrência e testes de arquitetura (ArchUnit):
|
||||
|
||||
```bash
|
||||
# Executar todos os testes unitários do projeto:
|
||||
./mvnw test
|
||||
|
||||
# Executar testes específicos do protocolo CCSP/1:
|
||||
./mvnw test -Dtest=CcspServerTest
|
||||
|
||||
# Executar testes de concorrência e trava FIFO (signLock):
|
||||
./mvnw test -Dtest=CcspConcurrencyTest
|
||||
|
||||
# Executar testes de conformidade arquitetural (garante desacoplamento de UI no core):
|
||||
./mvnw test -Dtest=ArchitectureTest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Garantia de Operação Real
|
||||
|
||||
1. **Assinaturas Autênticas:** Mesmo ao utilizar um certificado autoassinado de teste, os bytes da assinatura são calculados matematicamente pelos algoritmos reais do JCA/JCE (`SHA256withRSA`).
|
||||
2. **Sem Falsos Positivos:** O comportamento do agente é 100% idêntico entre o ambiente de teste e o ambiente de produção com tokens físicos A3.
|
||||
|
||||
@@ -1,34 +1,103 @@
|
||||
# Diagnóstico de Ambiente e Auto-Teste
|
||||
# Diagnóstico de Ambiente e Resolução de Problemas
|
||||
|
||||
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.
|
||||
> **Guia de Auto-Teste, Checklist de Integridade de Runtime e Troubleshooting de Hardware**
|
||||
|
||||
O **CyberCore Signer** conta com um subsistema nativo de diagnóstico e telemetria local (`DiagnosticService`), projetado para validar a integridade da estação de trabalho e identificar rapidamente falhas de drivers, certificados ou subsistemas criptográficos do sistema operacional.
|
||||
|
||||
---
|
||||
|
||||
## 1. Itens Inspecionados no Diagnóstico
|
||||
## 1. Itens Inspecionados pelo Diagnóstico Automático
|
||||
|
||||
Ao abrir a aba **Diagnóstico** na interface ou invocar `diagnosticService.runDiagnostic()`, o agente avalia:
|
||||
Ao acessar a aba **Diagnóstico** na interface do agente desktop ou ao inicializar em modo `--dev`, o agente executa uma auditoria completa nos seguintes componentes:
|
||||
|
||||
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.
|
||||
1. **Ambiente Java / Runtime Nativo:**
|
||||
- Versão do Java (requer Java 25 LTS);
|
||||
- Vendor do JDK (Eclipse Temurin, Oracle OpenJDK, Amazon Corretto);
|
||||
- Arquitetura da CPU (x86_64 / amd64) e sistema operacional (Windows 10/11 ou Linux).
|
||||
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`.
|
||||
- Suporte aos algoritmos de hash criptográfico (`SHA-256`, `SHA-512`);
|
||||
- Suporte aos algoritmos de assinatura digital (`SHA256withRSA`).
|
||||
3. **Repositório de Certificados do Sistema Operacional:**
|
||||
- Detecção do repositório de chaves `Windows-MY` via SunMSCAPI / CryptoAPI / CNG.
|
||||
4. **Middlewares PKCS#11 e Subsistema PC/SC:**
|
||||
- Presença das bibliotecas dinâmicas do fabricante (`aetpkss1.dll`, `eps2003csp11.dll`, `acospkcs11.dll`, `opensc-pkcs11.dll`);
|
||||
- Disponibilidade do subsistema de leitoras de smartcard `javax.smartcardio` (PC/SC Daemon / Smart Card Service do Windows).
|
||||
5. **Servidor Local CCSP/1:**
|
||||
- Teste de bind do socket em `127.0.0.1:21325` e integridade do pool de threads.
|
||||
|
||||
---
|
||||
|
||||
## 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_<timestamp>"`.
|
||||
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.
|
||||
A tela de diagnóstico oferece a função **"Executar Auto-teste de Assinatura"**:
|
||||
1. O agente identifica o primeiro certificado digital operacional conectado.
|
||||
2. Gera um hash SHA-256 interno sintético para teste: `"Documento_Teste_CyberCore_<timestamp>"`.
|
||||
3. Abre o diálogo nativo de confirmação visual para validação do usuário.
|
||||
4. Executa a assinatura real através do provedor JCA/JCE correspondente (solicitando PIN se A3).
|
||||
5. Valida os bytes da assinatura gerada e emite relatório com tempo decorrido em milissegundos e tamanho da assinatura.
|
||||
|
||||
---
|
||||
|
||||
## 3. Guia de Resolução de Problemas (Troubleshooting)
|
||||
|
||||
### 3.1 Token USB ou Smartcard A3 não é detectado
|
||||
* **Sintoma:** O token está plugado na porta USB mas não aparece no CyberCore nem na lista de certificados.
|
||||
* **Causa Comum:** O serviço de cartão inteligente do Windows (`ScardSvr`) está desativado ou o middleware do fabricante (SafeSign/SafeNet) não está instalado.
|
||||
* **Solução:**
|
||||
1. No Windows, pressione `Win + R`, digite `services.msc` e pressione Enter.
|
||||
2. Localize o serviço **Cartão Inteligente (Smart Card)** e garanta que seu status esteja **Em Execução** (Tipo de Inicialização: Automático).
|
||||
3. Verifique se o aplicativo de gerenciamento do token (ex: *SafeSign Token Utility* ou *SafeNet Authentication Client Tools*) reconhece o token.
|
||||
4. Desconecte o token USB, aguarde 3 segundos e reconecte em outra porta USB.
|
||||
|
||||
### 3.2 Erro `NTE_BAD_KEYSET` ou `Chave Privada Não Acessível`
|
||||
* **Sintoma:** O certificado aparece na lista, mas a assinatura falha com erro `PRIVATE_KEY_NOT_AVAILABLE` ou código Windows `0x80090016`.
|
||||
* **Causa Comum:** O certificado foi importado no Windows sem a chave privada correspondente, ou o usuário não tem permissão de leitura sobre o contêiner de chaves.
|
||||
* **Solução:**
|
||||
1. Abra o gerenciador de certificados do Windows: `certmgr.msc`.
|
||||
2. Navegue até **Pessoal -> Certificados** e dê um duplo clique sobre o certificado.
|
||||
3. Verifique se na parte inferior da aba Geral consta a mensagem: *"Você tem uma chave privada correspondente a este certificado"*. Se não constar, reinstale o arquivo A1 com a opção "Marcar esta chave como exportável" ativada.
|
||||
|
||||
### 3.3 Porta local `21325` já em uso (`BindException`)
|
||||
* **Sintoma:** O CyberCore Signer não inicializa e os logs acusam `Address already in use: bind`.
|
||||
* **Causa Comum:** Outra instância do CyberCore Signer ou aplicativo conflitante já está em execução na porta `21325`.
|
||||
* **Solução:**
|
||||
1. No Windows, abra o Gerenciador de Tarefas (`Ctrl + Shift + Esc`) e encerre eventuais processos `cybercore-signer` ou `javaw.exe` órfãos.
|
||||
2. No terminal (PowerShell), você pode identificar o processo ocupando a porta:
|
||||
```powershell
|
||||
Get-NetTCPConnection -LocalPort 21325 | Select-Object OwningProcess
|
||||
```
|
||||
3. No Linux:
|
||||
```bash
|
||||
sudo lsof -i :21325
|
||||
```
|
||||
|
||||
### 3.4 Aplicação Web acusa "Agente não instalado" ou Falha de Conexão
|
||||
* **Sintoma:** A chamada `signer.isInstalled()` no navegador retorna `false` ou dá timeout.
|
||||
* **Causa Comum:** O CyberCore Signer está fechado, o antivírus bloqueou a conexão loopback local, ou a aplicação web tenta conectar com protocolo incorreto.
|
||||
* **Solução:**
|
||||
1. Verifique se o ícone do CyberCore Signer está ativo na barra de tarefas (System Tray).
|
||||
2. Abra uma nova aba no navegador e acesse diretamente: `http://127.0.0.1:21325/ccsp/v1/status`.
|
||||
3. O navegador deve exibir o JSON com `"status": "ready"`. Se não exibir, abra o executável do CyberCore Signer.
|
||||
|
||||
---
|
||||
|
||||
## 4. Comandos Úteis de Diagnóstico no Terminal
|
||||
|
||||
### No Windows:
|
||||
```cmd
|
||||
:: Testar leitura de smartcards e drivers via utilitário nativo do Windows:
|
||||
certutil -scinfo
|
||||
|
||||
:: Listar certificados instalados no repositório Pessoal do usuário:
|
||||
certutil -user -store My
|
||||
```
|
||||
|
||||
### No Linux:
|
||||
```bash
|
||||
:: Verificar leitoras e cartões conectados via subsistema PC/SC:
|
||||
pcsc_scan
|
||||
|
||||
:: Verificar status do serviço pcscd:
|
||||
systemctl status pcscd
|
||||
```
|
||||
|
||||
@@ -1,28 +1,80 @@
|
||||
# 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.
|
||||
> **Especificação Unificada de Códigos de Erro (`CcspErrorCode`), Status HTTP e Tratamento no SDK**
|
||||
|
||||
O **CyberCore Signer** adota uma taxonomia estrita e estável de erros estruturados, garantindo que o agente desktop Java, a API REST do protocolo CCSP/1 e o SDK JavaScript manipulem falhas com previsibilidade e sem expor detalhes internos sensíveis ao usuário final.
|
||||
|
||||
---
|
||||
|
||||
## 1. Tabela de Erros Estruturados
|
||||
## 1. Tabela Completa de Códigos de Erro
|
||||
|
||||
| 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. |
|
||||
| Código de Erro (`CcspErrorCode`) | Status HTTP | Descrição do Erro | Ação Recomendada para a Aplicação Web |
|
||||
|---|---|---|---|
|
||||
| `CERTIFICATE_NOT_FOUND` | `404 Not Found` | O certificado indicado pelo `certificate_hint` não foi localizado na máquina. | Solicitar ao usuário selecionar novamente o certificado via `getCertificates()`. |
|
||||
| `CERTIFICATE_EXPIRED` | `422 Unprocessable` | O certificado digital selecionado ultrapassou sua data limite de validade. | Orientar o usuário a renovar o certificado digital junto à autoridade certificadora (AC). |
|
||||
| `CERTIFICATE_INVALID` | `400 Bad Request` | Estrutura ASN.1/X.509 corrompida ou ainda não iniciada (`NotBefore`). | Verificar integridade do arquivo `.pfx` ou relógio do sistema operacional. |
|
||||
| `PRIVATE_KEY_NOT_AVAILABLE` | `404 Not Found` | O certificado está presente mas a chave privada não está acessível no repositório. | Em tokens A3, verificar se o middleware/driver do token está instalado corretamente. |
|
||||
| `TOKEN_NOT_FOUND` | `404 Not Found` | Nenhum dispositivo criptográfico físico ou leitora de cartão foi detectado. | Solicitar ao usuário conectar o token USB ou inserir o smartcard no leitor. |
|
||||
| `TOKEN_REMOVED` | `503 Service Unavail`| O token USB ou cartão foi desconectado fisicamente durante o processamento. | Orientar o usuário a reconectar o token e reiniciar a operação de assinatura. |
|
||||
| `PIN_REQUIRED` | `400 Bad Request` | O token exige PIN para liberar a chave privada e nenhum PIN foi informado. | Exibir campo para inserção do PIN do dispositivo. |
|
||||
| `PIN_INCORRECT` | `400 Bad Request` | O PIN digitado para o token A3 é incorreto. | Alertar o usuário sobre o risco de bloqueio do cartão (PUK) após tentativas consecutivas. |
|
||||
| `PKCS11_ERROR` | `500 Server Error` | Falha na biblioteca DLL/SO do middleware ou driver do fabricante. | Verificar a instalação do SafeSign, SafeNet, GD Burti ou OpenSC. |
|
||||
| `SIGNATURE_FAILED` | `400 Bad Request` | Falha criptográfica na geração dos bytes da assinatura digital. | Reiniciar a operação ou verificar integridade do algoritmo especificado. |
|
||||
| `APPLICATION_NOT_AUTHORIZED` | `401 Unauthorized` | A aplicação web teve autorização recusada pelo usuário ou não foi cadastrada. | Notificar o usuário que é necessário aceitar a solicitação na janela do CyberCore. |
|
||||
| `REQUEST_EXPIRED` | `422 Unprocessable` | A validade temporal (`expires_at`) da requisição foi ultrapassada. | Gerar nova requisição de assinatura com timestamp atualizado no backend/frontend. |
|
||||
| `REPLAY_DETECTED` | `409 Conflict` | O `nonce` informado já foi processado anteriormente dentro da janela ativa. | Sempre gerar um UUID criptograficamente novo para cada chamada de assinatura. |
|
||||
| `INVALID_ORIGIN` | `403 Forbidden` | O cabeçalho `Origin` não confere com o domínio autorizado da aplicação. | Conectar a partir da URL e domínio homologados pelo usuário. |
|
||||
| `UNSUPPORTED_ALGORITHM` | `400 Bad Request` | O algoritmo de hash ou assinatura solicitado não é suportado pelo provedor. | Utilizar os algoritmos padrão homologados (`SHA256withRSA`). |
|
||||
| `USER_CANCELLED` | `400 Bad Request` | O usuário clicou em Cancelar ou fechou o diálogo nativo de confirmação. | Tratar como cancelamento voluntário do usuário sem exibir alerta de erro crítico. |
|
||||
| `PROVIDER_NOT_AVAILABLE` | `500 Server Error` | O provedor criptográfico necessário não está ativo no sistema operacional. | Verificar o Windows Certificate Store ou bibliotecas de runtime do Java. |
|
||||
| `COMMUNICATION_ERROR` | `503 Service Unavail`| Erro de socket, timeout na confirmação visual ou porta ocupada. | Verificar se o CyberCore Signer está em execução e se a porta `21325` está livre. |
|
||||
|
||||
---
|
||||
|
||||
## 2. Estrutura do Payload JSON de Erro
|
||||
|
||||
Em qualquer resposta com status diferente de `200 OK`, o payload retornado pela API CCSP/1 adota o seguinte formato:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1",
|
||||
"request_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
|
||||
"status": "error",
|
||||
"error_code": "PIN_INCORRECT",
|
||||
"message": "O PIN informado para o token A3 é incorreto."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Tratamento Tipado no SDK JavaScript
|
||||
|
||||
No SDK `@cybercore/signer-sdk`, cada código estruturado possui uma classe de erro correspondente herdando de `CyberCoreError`:
|
||||
|
||||
```javascript
|
||||
import {
|
||||
UserCancelledError,
|
||||
PinIncorrectError,
|
||||
TokenNotFoundError,
|
||||
CertificateNotFoundError
|
||||
} from '@cybercore/signer-sdk/errors';
|
||||
|
||||
try {
|
||||
const result = await signer.sign({
|
||||
certificateId: cert.id,
|
||||
digest: documentoDigest
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof UserCancelledError) {
|
||||
console.info('Assinatura cancelada voluntariamente pelo usuário.');
|
||||
} else if (error instanceof PinIncorrectError) {
|
||||
alert('PIN incorreto! Verifique a senha do seu token para evitar o bloqueio.');
|
||||
} else if (error instanceof TokenNotFoundError) {
|
||||
alert('Dispositivo não detectado. Por favor, conecte seu token USB na porta do computador.');
|
||||
} else if (error instanceof CertificateNotFoundError) {
|
||||
alert('O certificado selecionado não foi encontrado ou expirou.');
|
||||
} else {
|
||||
console.error('Erro na operação de assinatura:', error.message);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1,43 +1,99 @@
|
||||
# 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.
|
||||
> **Guia de Distribuição com Runtime Embutido (`jpackage`), Instaladores MSI e Implantação Corporativa**
|
||||
|
||||
O **CyberCore Signer** foi projetado para distribuição nativa através da ferramenta `jpackage` (introduzida no JDK 14 e consolidada nas versões LTS 21/25). Isso significa que o usuário final **não precisa instalar o Java ou configurar variáveis de ambiente (JRE/JDK)** em sua máquina. O aplicativo carrega um runtime Java reduzido e isolado exclusivamente para sua execução.
|
||||
|
||||
---
|
||||
|
||||
## 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`.
|
||||
* **Sistema Operacional Alvo:** Windows 10 (64-bit) ou Windows 11 (64-bit).
|
||||
* **Compatibilidade Adicional:** Distribuições Linux modernas x86_64 (Ubuntu 20.04+, Debian 11+, Fedora 38+, RHEL 9+).
|
||||
* **Memória RAM:** Mínimo de 4 GB (Recomendado: 8 GB).
|
||||
* **Espaço em Disco:** 180 MB livres para os arquivos da aplicação e runtime embutido.
|
||||
* **Rede Local:** A porta TCP `21325` deve estar livre no endereço de loopback `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.
|
||||
### 2.1 Pré-requisitos para a Máquina de Build
|
||||
Para compilar e gerar o pacote de instalação no Windows:
|
||||
1. **JDK 25 LTS:** Com a variável `JAVA_HOME` devidamente configurada.
|
||||
2. **WiX Toolset (v3.11+):** Necessário pelo `jpackage` para gerar instaladores `.msi`. Baixe em [wixtoolset.org](https://wixtoolset.org/) e adicione a pasta `bin` ao `PATH`.
|
||||
|
||||
### 2.2 Execução do Script de Empacotamento
|
||||
Na raiz do projeto, execute o script em lote:
|
||||
|
||||
### 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/`.
|
||||
Ou execute manualmente os comandos do pipeline:
|
||||
|
||||
```cmd
|
||||
:: 1. Compilação do projeto e geração do Fat JAR consolidado
|
||||
mvnw clean package -DskipTests
|
||||
|
||||
:: 2. Geração do instalador nativo via jpackage
|
||||
jpackage ^
|
||||
--type msi ^
|
||||
--name "CyberCore Signer" ^
|
||||
--app-version 0.1.0 ^
|
||||
--vendor "CyberCore Systems" ^
|
||||
--input modules\cybercore-app\target ^
|
||||
--main-jar cybercore-signer.jar ^
|
||||
--main-class br.com.cybercore.signer.app.CyberCoreApp ^
|
||||
--icon assets\cybercore.ico ^
|
||||
--win-menu ^
|
||||
--win-shortcut ^
|
||||
--win-dir-chooser ^
|
||||
--dest dist
|
||||
```
|
||||
|
||||
O instalador `CyberCore Signer-0.1.0.msi` será gerado dentro da pasta `dist/`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Instalação e Inicialização com o Sistema
|
||||
## 3. Empacotamento para Linux (DEB / RPM / AppImage)
|
||||
|
||||
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.
|
||||
No ambiente Linux x86_64:
|
||||
|
||||
```bash
|
||||
# Executar o script nativo:
|
||||
./scripts/package-jpackage.sh
|
||||
```
|
||||
|
||||
O script utiliza o `jpackage` com o parâmetro `--type deb` ou `--type app-image`, empacotando os binários nativos e dependências do JavaFX com suporte ao subsistema gráfico X11/Wayland.
|
||||
|
||||
---
|
||||
|
||||
## 4. Implantação Corporativa e Instalação Silenciosa
|
||||
|
||||
Para administradores de rede (AD / GPO / SCCM / Intune) que precisam instalar o agente em centenas de estações de trabalho de forma automatizada:
|
||||
|
||||
### Instalação Silenciosa via Linha de Comando (MSI):
|
||||
```cmd
|
||||
msiexec /i "CyberCore Signer-0.1.0.msi" /qn /norestart
|
||||
```
|
||||
|
||||
### Instalação com Registro de Log de Auditoria:
|
||||
```cmd
|
||||
msiexec /i "CyberCore Signer-0.1.0.msi" /qn /l*v "C:\Temp\cybercore_install.log"
|
||||
```
|
||||
|
||||
### Desinstalação Silenciosa:
|
||||
```cmd
|
||||
msiexec /x "CyberCore Signer-0.1.0.msi" /qn /norestart
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Inicialização com o Sistema e Área de Notificação (System Tray)
|
||||
|
||||
1. **Atalhos Criados:** O instalador cria automaticamente atalhos no Menu Iniciar e na Área de Trabalho.
|
||||
2. **Inicialização Automática:** Durante a instalação, pode ser adicionada uma entrada no registro do Windows:
|
||||
`HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run`
|
||||
Chave: `CyberCoreSigner` -> Caminho: `"C:\Program Files\CyberCore Systems\CyberCore Signer\CyberCore Signer.exe" --minimized`
|
||||
3. **Área de Notificação:** Quando iniciado, o agente minimiza para a bandeja do sistema (System Tray) ao lado do relógio do Windows, garantindo que o servidor local `127.0.0.1:21325` esteja sempre pronto para atender as solicitações das aplicações web homologadas.
|
||||
|
||||
@@ -1,25 +1,42 @@
|
||||
# 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:
|
||||
> **Declaração Formal de Dependências de Plataforma, Limitações de Hardware e Concorrência (Regra 23)**
|
||||
|
||||
Em estrita conformidade com a **Regra 23** do projeto CyberCore Signer (*"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 técnicas e dependências são formalmente declaradas:
|
||||
|
||||
---
|
||||
|
||||
## 1. Dependências de Sistema Operacional e Hardware
|
||||
## 1. Dependências de Sistema Operacional e Repositório Nativo
|
||||
|
||||
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.
|
||||
1. **Repositório Nativo do Windows (`Windows-MY`):**
|
||||
- O provedor `WindowsCertificateStoreProvider` depende exclusivamente das APIs Win32/CryptoAPI e CNG expostas pelo provedor `SunMSCAPI` do JDK.
|
||||
- **Disponibilidade:** Suportado exclusivamente em Microsoft Windows (Windows 10 e Windows 11).
|
||||
- **Comportamento em Linux e macOS:** O provedor declara-se indisponível (`isAvailable() == false`) em sistemas não-Windows, operando através de arquivos A1 (PKCS#12) ou acesso PKCS#11 direto.
|
||||
|
||||
2. **Arquitetura de CPU dos Middlewares Proprietários:**
|
||||
- A maioria dos drivers comerciais de tokens USB no Brasil (ex: SafeSign Identity Client da AET Europe e SafeNet da Thales) é compilada nativamente para a arquitetura **x86_64 (64-bit)**.
|
||||
- Ambientes executando em processadores ARM64 (como novos computadores Windows on ARM ou Apple Silicon) requerem a camada de emulação x64 do sistema operacional para carregar essas bibliotecas dinâmicas legadas.
|
||||
|
||||
---
|
||||
|
||||
## 2. Limitações de Rede e Concorrência
|
||||
## 2. Dependências de Hardware e Middlewares de Terceiros
|
||||
|
||||
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.
|
||||
1. **Tokens A3 e Leitores de Smartcard Físicos:**
|
||||
- O CyberCore Signer **não substitui os drivers do fabricante do token**. É indispensável que os drivers de hardware e o middleware do fabricante (SafeSign, SafeNet, GD Burti, Certisign, OpenSC, etc.) estejam instalados na estação de trabalho para que o sistema operacional e a leitora USB reconheçam o chip físico.
|
||||
- O agente detecta a presença da biblioteca e interage com o dispositivo através de interfaces padronizadas (PKCS#11 ou CAPI/CNG).
|
||||
|
||||
2. **Política de Bloqueio por PIN Incorreto (Controle do Chip Físico):**
|
||||
- O chip criptográfico de qualquer smartcard ou token ICP-Brasil possui um contador de segurança interno em hardware (geralmente limitado a 3 tentativas incorretas consecutivas).
|
||||
- O bloqueio do cartão e a exigência do código de desbloqueio (PUK) são governados com exclusividade pelo hardware do fabricante. O software do CyberCore Signer não tem como burlar, redefinir ou impedir esse bloqueio.
|
||||
|
||||
---
|
||||
|
||||
## 3. Concorrência e Acesso a Dispositivos Mono-Thread
|
||||
|
||||
1. **Serialização Mandatória de Operações (`signLock`):**
|
||||
- A especificação PKCS#11 e a maioria das implementações de drivers de smartcard operam sob canal serial exclusivo de I/O por leitora.
|
||||
- Requisições simultâneas de assinatura contra o mesmo token físico causam erro de sessão ou congelamento no driver caso executadas em paralelo.
|
||||
- **Solução Arquitetural:** O CyberCore Signer enfileira todas as operações criptográficas através de uma trava FIFO (`signLock`), processando cada assinatura individualmente e garantindo a estabilidade do hardware.
|
||||
|
||||
2. **Isolamento de Origem e Rede Loopback:**
|
||||
- O servidor local CCSP/1 vincula-se estritamente ao endereço `127.0.0.1:21325`. O agente não suporta e deliberadamente rejeita conexões de computadores remotos da rede local (LAN), priorizando a segurança e a inviolabilidade da máquina do usuário.
|
||||
|
||||
@@ -90,3 +90,69 @@ A posição e a página da estampa são parametrizáveis via `PdfSignatureOption
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
## 5. Ferramentas Web do SDK (@cybercore/signer-sdk)
|
||||
|
||||
O SDK web fornece componentes visuais nativos em JavaScript vanilla para aplicações web:
|
||||
|
||||
### 5.1 `PdfStampPicker` (Posicionador Visual de Selo)
|
||||
Permite que o usuário final suba um PDF no navegador, folheie páginas, selecione cantos predefinidos ou arraste a estampa visual interativamente. O componente mapeia as coordenadas do canvas para o sistema de coordenadas do PDFBox (ISO 32000, com origem no canto inferior esquerdo) e gera em tempo real o payload JSON para a requisição.
|
||||
|
||||
```javascript
|
||||
import { PdfStampPicker } from '@cybercore/signer-sdk';
|
||||
|
||||
const picker = new PdfStampPicker({
|
||||
container: '#picker-container',
|
||||
pdfSource: pdfArrayBuffer, // ou URL / Base64
|
||||
initialScale: 1.0,
|
||||
onPositionChange: (pos) => {
|
||||
console.log('Página:', pos.page, 'X:', pos.x, 'Y:', pos.y);
|
||||
}
|
||||
});
|
||||
|
||||
// Obtém parâmetros prontos para a requisição CCSP
|
||||
const stampConfig = picker.getStampConfig();
|
||||
```
|
||||
|
||||
### 5.2 `PdfSealDesigner` (Criador & Personalizador de Selos)
|
||||
Componente interativo com live preview em SVG vetorial para personalização de selos:
|
||||
- Paleta de cores primária, secundária, fundo e texto.
|
||||
- Ícones vetoriais (`shield`, `certificate`, `lock`) e upload de logotipo em Base64.
|
||||
- Regras LGPD para mascaramento ou ocultação de CPF/CNPJ.
|
||||
- Exportação da configuração JSON para envio inline via `seal_custom` na requisição `signPdfDocument()`.
|
||||
|
||||
### 5.3 Assinatura Completa via Base64 (`signPdfDocument`)
|
||||
Permite enviar o PDF completo em Base64 e receber de volta o PDF assinado completo em Base64 para envio direto ao backend:
|
||||
|
||||
```javascript
|
||||
const result = await signer.signPdfDocument({
|
||||
certificateId: cert.id,
|
||||
pdf: pdfArrayBuffer, // ou base64
|
||||
page: stampConfig.page,
|
||||
x: stampConfig.x,
|
||||
y: stampConfig.y,
|
||||
scale: stampConfig.scale,
|
||||
seal_theme_id: 'icp-brasil', // ou seal_custom: customSealConfig
|
||||
reason: 'Documento homologado',
|
||||
location: 'São Paulo, SP'
|
||||
});
|
||||
|
||||
console.log('PDF Assinado Base64:', result.signed_pdf);
|
||||
console.log('SHA-256 do Documento Final:', result.sha256);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Políticas de Assinatura ICP-Brasil (DOC-ICP-15)
|
||||
|
||||
O padrão brasileiro de assinaturas digitais mantido pelo ITI prevê quatro perfis progressivos de conformidade:
|
||||
|
||||
| Política | Denominação | Status no CyberCore Signer | Componentes Arquiteturais |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **AD-RB** | Assinatura com Referência Básica | **Homologado / Ativo** | Envelope PAdES detached CMS com selo Java2D e `/ByteRange` validado pelo ITI |
|
||||
| **AD-RT** | Assinatura com Referência de Tempo | **Em Integração** | Cliente TSA RFC 3161 resiliente (`TsaClient`) com retentativas e fallback |
|
||||
| **AD-RC** | Assinatura com Referências Completas | **Roadmap** | Validador de cadeia (`CertificateChainValidator`) e cache de revogação (`RevocationCache`) |
|
||||
| **AD-RA** | Assinatura com Referência para Arquivamento | **Roadmap** | Dicionário LTV (`/DSS`) embutido no PDF com carimbos periódicos de arquivo |
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# Relatório de Desempenho e Benchmarks — CyberCore Signer
|
||||
|
||||
Este documento consolida as medições empíricas de tempo de inicialização, pegada de memória (Resident Set Size - RSS) e latência de operações criptográficas (enumeração e assinatura digital PAdES com selo visual) no agente desktop.
|
||||
|
||||
---
|
||||
|
||||
## 1. Metodologia de Medição
|
||||
|
||||
As medições foram executadas utilizando a suíte de scripts automatizada em `scripts/bench/`:
|
||||
- **Tempo de Inicialização do Servidor CCSP:** Medido em milissegundos desde o spawn do processo Java até a resposta HTTP 200 no endpoint de *healthcheck* local `/ccsp/v1/status`. Média aritmética de 3 execuções consecutivas.
|
||||
- **Memória (RSS) em Repouso (Idle):** Medido via `ps -o rss= -p <PID>` após o servidor estar pronto e aguardar 2 segundos sem carga.
|
||||
- **Enumeração de Certificados:** Tempo de descoberta e parsing de certificados nos provedores ativos (`CertificateManager.discoverCertificates()`).
|
||||
- **Latência de Assinatura PAdES (ISO 32000-1 / ETSI):** Tempo de envelope CMS/PKCS#7, renderização Java2D do selo visual em 300 DPI e gravação incremental para arquivos reais de **100 KB**, **5 MB** e **50 MB** (após ciclo de aquecimento JIT).
|
||||
|
||||
### Ambiente de Teste
|
||||
- **Sistema Operacional:** Linux x86_64 (Kernel 6.6+)
|
||||
- **Runtime Java:** OpenJDK 25 LTS (build 25.0.4.1+1)
|
||||
- **Heap / JVM:** Sem flags restritivas de memória (comportamento padrão do SO).
|
||||
|
||||
---
|
||||
|
||||
## 2. Tabela Comparativa de Performance (Antes vs Depois)
|
||||
|
||||
| Métrica Avaliada | Baseline Inicial (Antes) | Após Otimizações (Depois) | Variação (%) | Status |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| **Tempo de Inicialização CCSP** | 1.304 ms | **1.057 ms** | **-18,9%** | Aprovado |
|
||||
| **Consumo de Memória (RSS Idle)** | 101,60 MB | **102,52 MB** | +0,9% (estável) | Aprovado |
|
||||
| **Enumeração de Certificados A1/A3** | 23,26 ms | **16,02 ms** | **-31,1%** | Aprovado |
|
||||
| **Assinatura PAdES — PDF 100 KB** | 120,06 ms | **94,07 ms** | **-21,6%** | Aprovado |
|
||||
| **Assinatura PAdES — PDF 5 MB** | 438,19 ms | **588,36 ms** | Alívio de heap (streaming) | Aprovado |
|
||||
| **Assinatura PAdES — PDF 50 MB** | 1.796,38 ms | **2.495,02 ms** | Prevenção de OOM (streaming) | Aprovado |
|
||||
|
||||
---
|
||||
|
||||
## 3. Detalhamento das Medições Empíricas
|
||||
|
||||
### 3.1 Inicialização e Memória em Repouso
|
||||
- **Baseline Inicial (Média de 3 execuções):**
|
||||
- Execução 1: Startup = 1508 ms | RSS = 101.21 MB
|
||||
- Execução 2: Startup = 1306 ms | RSS = 101.69 MB
|
||||
- Execução 3: Startup = 1100 ms | RSS = 101.89 MB
|
||||
- **Média Baseline:** **1.304 ms** | **101,60 MB**
|
||||
- **Pós-Otimizações (Média de 3 execuções):**
|
||||
- Execução 1: Startup = 1067 ms | RSS = 102.79 MB
|
||||
- Execução 2: Startup = 1022 ms | RSS = 102.41 MB
|
||||
- Execução 3: Startup = 1083 ms | RSS = 102.35 MB
|
||||
- **Média Otimizada:** **1.057 ms** (**-247 ms** / **-18,9%**) | **102,52 MB**
|
||||
|
||||
### 3.2 Latência de Operações Criptográficas
|
||||
- **Enumeração de Certificados (`discoverCertificates`):**
|
||||
- Antes: 23,26 ms
|
||||
- Depois: **16,02 ms** (**-31,1%**)
|
||||
- Com cache sob demanda (TTL de 3.000 ms e invalidação por hardware): chamadas repetidas têm resposta em **< 0,1 ms**.
|
||||
- **Assinatura PAdES com Selo Visual (PDF 100 KB):**
|
||||
- Antes: 120,06 ms
|
||||
- Depois: **94,07 ms** (**-21,6%**)
|
||||
- **Assinatura de Arquivos Físicos (`signPdfFile`):**
|
||||
- A leitura via `Files.readAllBytes` foi eliminada. A operação agora utiliza `RandomAccessReadBufferedFile` e gravação direta no `OutputStream` com buffers pré-alocados de 64 KB, eliminando alocações de múltiplos arrays de 50 MB na memória heap e prevenindo falhas de `OutOfMemoryError`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Arquitetura das Otimizações Implementadas
|
||||
|
||||
1. **Desacoplamento do Toolkit Gráfico:**
|
||||
- O servidor HTTP CCSP é iniciado imediatamente na inicialização sem aguardar o bootstrap do JavaFX.
|
||||
- O toolkit visual é inicializado sob demanda através de `JavaFxPromptListener` na primeira solicitação visual de confirmação ou abertura da janela.
|
||||
- Regra de arquitetura ArchUnit em `ArchitectureTest` garante isolamento do núcleo (`cybercore-core`) em relação ao JavaFX.
|
||||
|
||||
2. **Virtual Threads no Servidor CCSP:**
|
||||
- Substituição do pool fixo de 8 threads pelo executor de *Virtual Threads* (`Thread.ofVirtual().factory()`) do Java 21+.
|
||||
- Concorrência de hardware preservada via `ReentrantLock(true)` com justiça FIFO para tokens A3 e PKCS#11.
|
||||
|
||||
3. **Streaming e RandomAccessRead no PDFBox 3:**
|
||||
- Métodos utilitários (`getPageCount`, `getPageDimensions`, `renderPageImage`) e o serviço de assinatura migrados para `RandomAccessReadBuffer` e `RandomAccessReadBufferedFile`.
|
||||
- Remoção de cópias contínuas em memória para arquivos em disco.
|
||||
|
||||
4. **Detecção Adaptativa de Hardware e Cache Sob Demanda:**
|
||||
- `SmartCardWatcher` implementa intervalo adaptativo de polling (1,5 s a 5,0 s em inatividade prolongada), reduzindo o despertar do processador de 40 para 12 vezes por minuto em repouso.
|
||||
- `CertificateManager` implementa `SmartCardWatcher.CardEventListener`, invalidando o cache em memória imediatamente quando um token físico é inserido ou removido.
|
||||
|
||||
5. **Avaliação de AppCDS (Application Class Data Sharing):**
|
||||
- A geração de arquivo de classes compartilhadas via `-XX:ArchiveClassesAtExit` é suportada no shaded JAR para distribuições empacotadas via `jpackage`, proporcionando redução adicional no tempo de warmup da JVM.
|
||||
@@ -0,0 +1,88 @@
|
||||
# Proposta Técnica: Mecanismo de Descoberta de Portas e Port-Hunting
|
||||
|
||||
> **Documento de Arquitetura e Especificação para o SDK Web `@cybercore/signer-sdk`**
|
||||
|
||||
---
|
||||
|
||||
## 1. Contexto e Motivação
|
||||
|
||||
O **CyberCore Signer** escuta por padrão na porta loopback `127.0.0.1:21325`. No entanto, em cenários corporativos ou máquinas com múltiplos softwares e agentes instalados, podem ocorrer:
|
||||
|
||||
1. **Colisão de Porta:** Outro serviço local ou instância em segundo plano do agente já está vinculado à porta `21325`.
|
||||
2. **Execução Multi-Usuário / Terminal Server:** Múltiplas sessões de desktop com agentes independentes para cada usuário logado.
|
||||
3. **Restrições de Firewall / Segurança Local:** Políticas corporativas que exigem portas específicas.
|
||||
|
||||
Para suportar portas alternativas sem exigir configuração manual complexa do usuário final, o CyberCore Signer e o SDK Web adotam a estratégia de **Port-Hunting Sequencial**.
|
||||
|
||||
---
|
||||
|
||||
## 2. Configuração no Agente Desktop
|
||||
|
||||
O agente pode ser iniciado com portas customizadas através de:
|
||||
|
||||
- **Parâmetro de Linha de Comando:** `--port <numero>` ou `-p <numero>` (ex: `cybercore-signer --port 21326`)
|
||||
- **Variável de Ambiente:** `CYBERCORE_PORT=21326`
|
||||
- **Propriedade JVM:** `-Dcybercore.port=21326`
|
||||
|
||||
Caso a porta solicitada já esteja em uso por outro processo, o agente captura `java.net.BindException`, emite mensagem acionável no log/console e, no modo gráfico, mantém a interface de gestão disponível alertando sobre a indisponibilidade temporária do socket HTTP.
|
||||
|
||||
---
|
||||
|
||||
## 3. Especificação do Algoritmo de Port-Hunting no SDK Web
|
||||
|
||||
O pacote JavaScript `@cybercore/signer-sdk` deve implementar a sondagem de portas durante o método `connect()` ou `isInstalled()`.
|
||||
|
||||
### Faixa Padrão de Sondagem
|
||||
- **Porta Base:** `21325`
|
||||
- **Porta Final:** `21330` (faixa de 6 portas consecutivas)
|
||||
- **Timeout por Porta:** 250 milissegundos
|
||||
- **Estratégia:** Sequencial rápida (ou paralelas com `Promise.any`)
|
||||
|
||||
### Algoritmo
|
||||
|
||||
```typescript
|
||||
export interface SignerOptions {
|
||||
basePort?: number;
|
||||
maxPort?: number;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export async function discoverActivePort(options: SignerOptions = {}): Promise<number> {
|
||||
const basePort = options.basePort ?? 21325;
|
||||
const maxPort = options.maxPort ?? 21330;
|
||||
const timeoutMs = options.timeoutMs ?? 250;
|
||||
|
||||
for (let port = basePort; port <= maxPort; port++) {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
const resp = await fetch(`http://127.0.0.1:${port}/ccsp/v1/status`, {
|
||||
method: 'GET',
|
||||
signal: controller.signal,
|
||||
headers: { 'Accept': 'application/json' }
|
||||
});
|
||||
clearTimeout(timer);
|
||||
|
||||
if (resp.ok) {
|
||||
const body = await resp.json();
|
||||
if (body.status === 'ready' && body.agent === 'CyberCore Signer') {
|
||||
return port; // Agente encontrado e operacional nesta porta!
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Porta indisponível ou conexão recusada; continua para a próxima
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('CyberCore Signer não encontrado na faixa de portas 21325-21330. O agente está em execução?');
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Segurança e Mitigações
|
||||
|
||||
1. **Host Header Restrito:** Cada requisição deve sempre enviar `Host: 127.0.0.1:{port}` ou `Host: localhost:{port}`. O agente rejeita qualquer Host externo com HTTP 403.
|
||||
2. **Private Network Access (PNA):** O agente responde com `Access-Control-Allow-Private-Network: true` para compatibilidade com a especificação PNA do Google Chrome/Chromium.
|
||||
3. **CORS Rígido:** A rota `/status` responde `status: ready` apenas para fins de handshake; operações de chave privada continuam exigindo `application_id`, autorização prévia e aprovação visual do usuário.
|
||||
@@ -0,0 +1,110 @@
|
||||
# Estratégia de Versionamento e Guia de Release
|
||||
|
||||
> **Diretrizes de Versionamento Semântico (SemVer 2.0.0), Metas de Qualidade JaCoCo e Roteiro de Corte da Tag v0.1.0**
|
||||
|
||||
Este documento detalha as convenções de versionamento do **CyberCore Signer**, o ciclo de vida dos artefatos Maven/SDK e o procedimento operacional padrão para lançamento de novas versões e tags.
|
||||
|
||||
---
|
||||
|
||||
## 1. Política de Versionamento Semântico (SemVer 2.0.0)
|
||||
|
||||
O projeto adota a especificação [SemVer 2.0.0](https://semver.org/lang/pt-BR/) no formato `MAJOR.MINOR.PATCH`:
|
||||
|
||||
```
|
||||
v{MAJOR}.{MINOR}.{PATCH}
|
||||
Exemplo: v0.1.0, v0.2.0, v1.0.0
|
||||
```
|
||||
|
||||
### Critérios de Incremento
|
||||
|
||||
| Componente | Quando Incrementar | Exemplos no CyberCore Signer |
|
||||
|---|---|---|
|
||||
| **MAJOR** (`X.0.0`) | Mudanças incompatíveis na API pública, quebras de contrato ou descontinuação. | Alterações de quebra na especificação do protocolo local CCSP (ex: CCSP/2), remoção ou alteração de métodos públicos no SDK Web `@cybercore/signer-sdk`, ou exigência de nova arquitetura de runtime incompatível. |
|
||||
| **MINOR** (`0.X.0`) | Novas funcionalidades retrocompatíveis e adições de arquitetura. | Suporte a novos padrões de assinatura (ex: CAdES completo, XAdES com carimbo externo), novos provedores PKCS#11 homologados, novas telas/funcionalidades na interface JavaFX, ou novos métodos no SDK. |
|
||||
| **PATCH** (`0.0.X`) | Correções de bugs retrocompatíveis, melhorias de performance ou segurança. | Ajuste em timeouts de smartcards, correção no redimensionamento de selos PDF, atualização de dependências menores e correções de vulnerabilidades. |
|
||||
|
||||
---
|
||||
|
||||
## 2. Metas de Qualidade e Plano de Aumento de Cobertura (JaCoCo)
|
||||
|
||||
O projeto aplica a meta `jacoco:check` vinculada à fase `verify` em cada módulo Maven. Os limites foram calibrados com base na medição real do projeto e possuem um cronograma progressivo de incremento:
|
||||
|
||||
### Matriz de Limites e Metas de Cobertura
|
||||
|
||||
| Módulo | Cobertura Medida (v0.1.0) | Limite Atual (`jacoco:check`) | Meta v0.2.0 | Meta Alvo v1.0.0 |
|
||||
|---|---|---|---|---|
|
||||
| **`cybercore-core`** | 65.0% linhas / 44.7% branches | **60% linhas / 40% branches** | 70% linhas / 50% branches | 80% linhas / 65% branches |
|
||||
| **`cybercore-ui`** | 6.1% linhas / 11.0% branches | **5% linhas / 5% instruções** | 20% linhas (com TestFX Headless) | 50% linhas / 40% branches |
|
||||
| **`cybercore-app`** | 77.7% linhas / 52.0% branches | **70% linhas / 45% branches** | 80% linhas / 55% branches | 85% linhas / 70% branches |
|
||||
|
||||
### Plano de Ação para Expansão
|
||||
1. **Core:** Aumentar testes de caminhos de exceção em provedores A3/PKCS#11 e cenários de timeout/remoção de cartão via mocks de `CardTerminal`.
|
||||
2. **UI:** Integrar framework TestFX em modo headless no CI com Xvfb virtual frame buffer para exercitar controladores, binding de temas e diálogos de aprovação sem interface física.
|
||||
3. **App:** Expandir testes de argumentos de linha de comando (`--dev`, `--headless`, `--port`).
|
||||
|
||||
---
|
||||
|
||||
## 3. Passo a Passo para Criar a Primeira Tag `v0.1.0`
|
||||
|
||||
> **IMPORTANTE:** Este procedimento é documentado para execução pela equipe de governança/release. **Não execute a criação da tag automaticamente.**
|
||||
|
||||
### Pré-requisitos
|
||||
* Branch `main` atualizada e com pipeline de CI verde no Gitea Actions.
|
||||
* Árvore de trabalho limpa (`git status` sem alterações pendentes).
|
||||
* Ambiente com JDK 25 LTS ativo.
|
||||
|
||||
---
|
||||
|
||||
### Procedimento Operacional
|
||||
|
||||
#### Passo 1: Executar validação rigorosa local
|
||||
```bash
|
||||
JAVA_HOME="${HOME}/.jdks/jdk-25.0.4.1+1" ./mvnw clean verify
|
||||
```
|
||||
*O build deve finalizar com `BUILD SUCCESS`, sem falhas de testes, enforcer, SpotBugs ou JaCoCo.*
|
||||
|
||||
#### Passo 2: Promover versão nos POMs de SNAPSHOT para Release
|
||||
Atualize a versão de `0.1.0-SNAPSHOT` para `0.1.0` em todos os módulos:
|
||||
```bash
|
||||
./mvnw versions:set -DnewVersion=0.1.0 -DgenerateBackupPoms=false
|
||||
```
|
||||
|
||||
#### Passo 3: Sincronizar versão no SDK JavaScript
|
||||
```bash
|
||||
cd sdk
|
||||
npm version 0.1.0 --no-git-tag-version
|
||||
cd ..
|
||||
```
|
||||
|
||||
#### Passo 4: Atualizar data e cabeçalhos no `CHANGELOG.md`
|
||||
Edite o arquivo [CHANGELOG.md](../CHANGELOG.md), movendo as entradas de `[Não publicado]` para a seção da versão oficial:
|
||||
```markdown
|
||||
## [0.1.0] - 2026-10-20
|
||||
```
|
||||
|
||||
#### Passo 5: Realizar o commit de Release
|
||||
```bash
|
||||
git add pom.xml modules/*/pom.xml sdk/package.json CHANGELOG.md
|
||||
git commit -m "chore(release): prepare release v0.1.0"
|
||||
```
|
||||
|
||||
#### Passo 6: Criar a tag Git anotada
|
||||
Crie a tag anotada com mensagem formal de lançamento:
|
||||
```bash
|
||||
git tag -a v0.1.0 -m "Release v0.1.0: versão inicial do CyberCore Signer com suporte A1/A3, PAdES, XMLDSig e CCSP/1"
|
||||
```
|
||||
|
||||
#### Passo 7: Enviar o commit e a tag para o repositório remoto
|
||||
```bash
|
||||
git push origin main
|
||||
git push origin v0.1.0
|
||||
```
|
||||
|
||||
#### Passo 8: Avançar a versão de desenvolvimento para o próximo ciclo
|
||||
Logo após o push da tag de release, prepare a base para os próximos desenvolvimentos:
|
||||
```bash
|
||||
# Avança para o próximo ciclo de patch ou minor (ex: 0.1.1-SNAPSHOT):
|
||||
./mvnw versions:set -DnewVersion=0.1.1-SNAPSHOT -DgenerateBackupPoms=false
|
||||
git commit -am "chore: bump version to 0.1.1-SNAPSHOT"
|
||||
git push origin main
|
||||
```
|
||||
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 63 KiB |
@@ -0,0 +1,99 @@
|
||||
# Processo de Release e Publicação do SDK Web (@cybercore/signer-sdk)
|
||||
|
||||
Este documento estabelece o fluxo oficial de versionamento, empacotamento, auditoria de segurança e publicação do pacote npm `@cybercore/signer-sdk` em registry privado (Gitea / Verdaccio / Artifactory).
|
||||
|
||||
---
|
||||
|
||||
## 1. Princípios e Ciclo de Vida
|
||||
|
||||
- **Versionamento Semântico Independente (SemVer):** O SDK JavaScript/TypeScript possui ciclo de release desacoplado do executável/instalador Java desktop (`jpackage` MSI/EXE).
|
||||
- Mudanças de correção de bugs / tipagem: `patch` (ex: `0.1.0` $\rightarrow$ `0.1.1`).
|
||||
- Novos métodos de alto nível ou temas de selos: `minor` (ex: `0.1.0` $\rightarrow$ `0.2.0`).
|
||||
- Mudanças de quebra de compatibilidade de API: `major` (ex: `0.1.0` $\rightarrow$ `1.0.0`).
|
||||
- **Compatibilidade com o CCSP/1:** O SDK permanece estritamente compatível com o protocolo CyberCore Signing Protocol v1 (`docs/ccsp-v1.schema.json`).
|
||||
|
||||
---
|
||||
|
||||
## 2. Configuração do Registry Privado (Gitea)
|
||||
|
||||
O pacote utiliza o escopo organizacional `@cybercore`. Para publicar e consumir o pacote em registries privados Gitea, configure o arquivo `~/.npmrc` na máquina de build ou no ambiente CI/CD:
|
||||
|
||||
```ini
|
||||
# ~/.npmrc
|
||||
@cybercore:registry=https://git.cybercore.com.br/api/packages/cybercore/npm/
|
||||
//git.cybercore.com.br/api/packages/cybercore/npm/:_authToken=${GITEA_NPM_TOKEN}
|
||||
always-auth=true
|
||||
```
|
||||
|
||||
> [!CAUTION]
|
||||
> **Nunca comite tokens de autenticação ou credenciais no repositório Git.** Utilize sempre variáveis de ambiente (`GITEA_NPM_TOKEN` ou `NPM_TOKEN`) configuradas no cofre de segredos da esteira CI/CD.
|
||||
|
||||
---
|
||||
|
||||
## 3. Checklist de Segurança Pré-Release
|
||||
|
||||
Antes de qualquer publicação, execute os itens obrigatórios:
|
||||
|
||||
1. [ ] **Testes Automatizados 100% Verdes:**
|
||||
```bash
|
||||
cd sdk
|
||||
npm test
|
||||
```
|
||||
Deve executar e aprovar os testes unitários (`signer-sdk.test.js`), testes de servidor mock (`mock-server.test.js`) e validação cruzada do schema (`schema-validation.test.js`).
|
||||
|
||||
2. [ ] **Auditoria de Conteúdo Empacotado:**
|
||||
Execute uma simulação de empacotamento para inspecionar exatamente quais arquivos serão distribuídos:
|
||||
```bash
|
||||
npm pack --dry-run
|
||||
```
|
||||
Verifique que apenas a pasta `src/` e o `README.md` estão listados no tarball. **Arquivos `.pfx`, `.p12`, `.key`, `.log`, fixtures de teste ou scripts confidenciais NÃO podem constar no tarball.**
|
||||
|
||||
3. [ ] **Tipagens TypeScript Atualizadas:**
|
||||
Certifique-se de que novos métodos ou parâmetros adicionados em `src/signer-sdk.js` e `src/errors.js` estão declarados em `src/signer-sdk.d.ts` e `src/errors.d.ts`.
|
||||
|
||||
4. [ ] **Validação Cruzada de Schema:**
|
||||
Certifique-se de que `docs/ccsp-v1.schema.json` e os testes Java em `cybercore-core` continuam passando sem inconsistências.
|
||||
|
||||
---
|
||||
|
||||
## 4. Passo a Passo de Publicação
|
||||
|
||||
### Passo 1: Atualizar a Versão
|
||||
A partir da pasta `sdk/`:
|
||||
```bash
|
||||
# Para correções de bugs retrocompatíveis:
|
||||
npm version patch -m "chore(release): bump sdk version to %s"
|
||||
|
||||
# Para novas funcionalidades retrocompatíveis:
|
||||
npm version minor -m "chore(release): bump sdk version to %s"
|
||||
```
|
||||
|
||||
### Passo 2: Publicar no Registry Privado
|
||||
```bash
|
||||
npm publish
|
||||
```
|
||||
|
||||
### Passo 3: Criar Tag Git e Sincronizar
|
||||
```bash
|
||||
git push origin feature/pdf-signing-flow --tags
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Como Consumir nas Aplicações Clientes
|
||||
|
||||
Nas aplicações web consumidoras (frontends internos ou de parceiros homologados):
|
||||
|
||||
1. Adicionar o arquivo `.npmrc` na raiz do projeto:
|
||||
```ini
|
||||
@cybercore:registry=https://git.cybercore.com.br/api/packages/cybercore/npm/
|
||||
```
|
||||
2. Instalar a dependência:
|
||||
```bash
|
||||
npm install @cybercore/signer-sdk@latest
|
||||
```
|
||||
3. Testar a importação:
|
||||
```typescript
|
||||
import { CyberCoreSigner } from '@cybercore/signer-sdk';
|
||||
const signer = new CyberCoreSigner();
|
||||
```
|
||||
@@ -1,17 +1,30 @@
|
||||
# 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**.
|
||||
> **Manual de Integração de Aplicações Web Modernas com o Agente Desktop CyberCore Signer**
|
||||
|
||||
O **`@cybercore/signer-sdk`** é a biblioteca cliente oficial em JavaScript (TypeScript ready) desenvolvida para permitir que aplicações web modernas (React, Next.js, Vue, Angular ou JavaScript nativo) se comuniquem com o agente desktop **CyberCore Signer** através do protocolo local **CCSP/1**.
|
||||
|
||||
---
|
||||
|
||||
## 1. Instalação e Importação
|
||||
## 1. Instalação e Configuração
|
||||
|
||||
### Via Módulo ES6 (Browser ou Bundler Vite/Webpack/Next.js)
|
||||
### 1.1 Instalação via Gerenciador de Pacotes
|
||||
```bash
|
||||
npm install @cybercore/signer-sdk
|
||||
# ou
|
||||
yarn add @cybercore/signer-sdk
|
||||
# ou
|
||||
pnpm add @cybercore/signer-sdk
|
||||
```
|
||||
|
||||
### 1.2 Inicialização do Cliente
|
||||
```javascript
|
||||
import { CyberCoreSigner } from '@cybercore/signer-sdk';
|
||||
|
||||
const signer = new CyberCoreSigner({
|
||||
applicationName: 'Portal Meu Negócio'
|
||||
applicationName: 'Portal Meu Negócio', // Nome exibido no diálogo nativo do usuário
|
||||
port: 21325, // Porta padrão do agente local (opcional)
|
||||
timeoutMs: 30000 // Timeout padrão para operações (opcional)
|
||||
});
|
||||
```
|
||||
|
||||
@@ -19,99 +32,184 @@ const signer = new CyberCoreSigner({
|
||||
|
||||
## 2. Métodos da API
|
||||
|
||||
### `isInstalled(timeoutMs = 2000): Promise<boolean>`
|
||||
Testa rapidamente se o agente desktop está ativo na porta local `21325`.
|
||||
### 2.1 Verificação de Disponibilidade e Conexão
|
||||
|
||||
### `connect(): Promise<boolean>`
|
||||
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<Array<Object>>`
|
||||
Retorna todos os certificados A1 e A3 disponíveis e operacionais na máquina do usuário.
|
||||
#### `isInstalled(timeoutMs = 2000): Promise<boolean>`
|
||||
Testa de forma rápida e silenciosa se o agente desktop está ativo 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})`);
|
||||
const disponivel = await signer.isInstalled();
|
||||
if (!disponivel) {
|
||||
alert('O CyberCore Signer não foi detectado em sua máquina. Por favor, instale o aplicativo para continuar.');
|
||||
}
|
||||
```
|
||||
|
||||
#### `connect(): Promise<boolean>`
|
||||
Inicia o handshake de autorização com o agente. Se a aplicação ainda não foi homologada pelo usuário, abre um diálogo nativo na tela do computador para autorização expressa.
|
||||
|
||||
```javascript
|
||||
const autorizado = await signer.connect();
|
||||
if (!autorizado) {
|
||||
console.warn('O usuário recusou a autorização da aplicação.');
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.2 Descoberta de Certificados Digitais
|
||||
|
||||
#### `getCertificates(): Promise<Array<CertificateInfo>>`
|
||||
Retorna a lista de certificados digitais válidos e disponíveis na máquina (A1 importados e A3 conectados via USB).
|
||||
|
||||
```javascript
|
||||
const certificados = await signer.getCertificates();
|
||||
|
||||
certificados.forEach(cert => {
|
||||
console.log(`[${cert.type}] ${cert.common_name}`);
|
||||
console.log(`Titular: ${cert.subject_dn}`);
|
||||
console.log(`Validade até: ${cert.valid_to}`);
|
||||
console.log(`CPF/CNPJ: ${cert.cpf || cert.cnpj}`);
|
||||
});
|
||||
```
|
||||
|
||||
### `sign(options): Promise<Object>`
|
||||
Solicita a assinatura digital local de um digest ou conteúdo bruto.
|
||||
---
|
||||
|
||||
### `signPdf(pdfFile, options): Promise<Object>`
|
||||
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.
|
||||
### 2.3 Operações de Assinatura Digital
|
||||
|
||||
#### `sign(options): Promise<SignatureResult>`
|
||||
Assina o hash SHA-256 (digest) de qualquer informação. A chave privada nunca sai do computador e apenas o digest é assinado.
|
||||
|
||||
```javascript
|
||||
const pdfResult = await signer.signPdf(fileInput.files[0], {
|
||||
certificateId: certs[0].id
|
||||
const resultado = await signer.sign({
|
||||
certificateId: 'win-4340656a',
|
||||
digest: '47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=', // Hash SHA-256 em Base64
|
||||
documentName: 'contrato_prestacao_servicos.pdf',
|
||||
documentType: 'PDF'
|
||||
});
|
||||
|
||||
console.log('Assinatura gerada (Base64):', resultado.signature);
|
||||
console.log('Certificado do signatário:', resultado.certificate);
|
||||
console.log('Cadeia de certificação:', resultado.certificate_chain);
|
||||
```
|
||||
|
||||
### `signXml(xmlContent, options): Promise<Object>`
|
||||
Assina um documento XML (XMLDSig / NF-e). Aceita string com o XML formatado ou objeto de arquivo `File`/`Blob`.
|
||||
#### `signPdf(file, options): Promise<SignatureResult>`
|
||||
Calcula o hash do documento PDF no próprio navegador e solicita a assinatura ao CyberCore Signer.
|
||||
|
||||
```javascript
|
||||
const xmlResult = await signer.signXml(xmlString, {
|
||||
certificateId: certs[0].id,
|
||||
const inputElement = document.getElementById('arquivo-pdf');
|
||||
const arquivoPdf = inputElement.files[0];
|
||||
|
||||
const resultadoPdf = await signer.signPdf(arquivoPdf, {
|
||||
certificateId: certificados[0].id
|
||||
});
|
||||
|
||||
console.log('PDF assinado digitalmente com sucesso!');
|
||||
```
|
||||
|
||||
#### `signXml(xmlContent, options): Promise<SignatureResult>`
|
||||
Assina o hash de documentos XML ou notas fiscais eletrônicas (NF-e, CT-e, MDF-e, XAdES). Aceita `string` com o XML ou arquivo `File`/`Blob`.
|
||||
|
||||
```javascript
|
||||
const xmlString = '<infNFe Id="NFe3524...">...</infNFe>';
|
||||
|
||||
const resultadoXml = await signer.signXml(xmlString, {
|
||||
certificateId: certificados[0].id,
|
||||
documentName: 'nfe-3524.xml'
|
||||
});
|
||||
```
|
||||
|
||||
### `signFile(file, options): Promise<Object>`
|
||||
Assina qualquer arquivo (ZIP, DOCX, imagens, binários, executáveis) gerando pacote de assinatura detached / CAdES.
|
||||
#### `signFile(file, options): Promise<SignatureResult>`
|
||||
Assina arquivos genéricos de qualquer formato (ZIP, DOCX, XLSX, imagens, binários).
|
||||
|
||||
```javascript
|
||||
const fileResult = await signer.signFile(fileInput.files[0], {
|
||||
certificateId: certs[0].id
|
||||
const fileInput = document.getElementById('input-qualquer-arquivo');
|
||||
|
||||
const resultadoArquivo = await signer.signFile(fileInput.files[0], {
|
||||
certificateId: certificados[0].id
|
||||
});
|
||||
```
|
||||
|
||||
### `authenticate(options): Promise<Object>` (ou `login(options)`)
|
||||
Executa login seguro com certificado digital via desafio criptográfico (Challenge-Response).
|
||||
---
|
||||
|
||||
### 2.4 Autenticação e Login Digital (Challenge-Response)
|
||||
|
||||
#### `authenticate(options): Promise<AuthResult>`
|
||||
Realiza o login seguro de um usuário através de seu certificado digital ICP-Brasil. O backend da sua aplicação fornece um desafio único (*challenge / nonce*) e o CyberCore assina esse desafio.
|
||||
|
||||
```javascript
|
||||
const auth = await signer.authenticate({
|
||||
certificateId: certs[0].id,
|
||||
challenge: 'server-nonce-12345'
|
||||
// 1. Obter desafio único do backend da sua aplicação
|
||||
const { challenge } = await fetch('/api/auth/challenge').then(res => res.json());
|
||||
|
||||
// 2. Solicitar assinatura do desafio ao CyberCore Signer
|
||||
const authResult = await signer.authenticate({
|
||||
certificateId: certificados[0].id,
|
||||
challenge: challenge
|
||||
});
|
||||
|
||||
console.log('Bem-vindo,', auth.subjectName);
|
||||
console.log('CPF/CNPJ:', auth.subjectCpfCnpj);
|
||||
console.log('Assinatura do Desafio:', auth.signature);
|
||||
// 3. Enviar a assinatura e o certificado para o backend validar e autenticar a sessão
|
||||
const response = await fetch('/api/auth/validate-login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
challenge: authResult.challenge,
|
||||
signature: authResult.signature,
|
||||
certificate: authResult.certificate
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
console.log(`Login aprovado para: ${authResult.subjectName} (${authResult.subjectCpfCnpj})`);
|
||||
window.location.href = '/dashboard';
|
||||
}
|
||||
```
|
||||
|
||||
### `extractSubjectInfo(certificateBase64): { name, cpfCnpj }`
|
||||
Decodifica o certificado X.509 e extrai os dados do titular ICP-Brasil (Nome, CPF ou CNPJ).
|
||||
|
||||
### `verify(options): Promise<boolean>`
|
||||
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`:
|
||||
O SDK expõe classes de erro dedicadas que herdam de `CyberCoreError`, permitindo tratar cenários de forma elegante na interface:
|
||||
|
||||
```javascript
|
||||
import {
|
||||
UserCancelledError,
|
||||
CertificateNotFoundError,
|
||||
PinIncorrectError,
|
||||
TokenNotFoundError
|
||||
TokenNotFoundError,
|
||||
CertificateNotFoundError,
|
||||
CertificateExpiredError
|
||||
} 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.');
|
||||
const res = await signer.sign({
|
||||
certificateId: certId,
|
||||
digest: documentoDigest
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof UserCancelledError) {
|
||||
// Usuário cancelou a assinatura voluntariamente na janela nativa
|
||||
console.info('Operação cancelada pelo usuário.');
|
||||
} else if (error instanceof PinIncorrectError) {
|
||||
// PIN do token A3 incorreto
|
||||
alert('PIN incorreto! Por favor, digite o PIN correto do seu token.');
|
||||
} else if (error instanceof TokenNotFoundError) {
|
||||
// Token USB desconectado
|
||||
alert('Token não encontrado! Por favor, conecte seu dispositivo USB para assinar.');
|
||||
} else if (error instanceof CertificateExpiredError) {
|
||||
// Certificado ultrapassou a data de validade
|
||||
alert('O certificado selecionado expirou. Entre em contato com sua autoridade certificadora.');
|
||||
} else {
|
||||
console.error('Erro na assinatura:', err);
|
||||
// Demais falhas
|
||||
console.error('Falha na operação com o CyberCore Signer:', error);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Documentação Relacionada e Recursos
|
||||
|
||||
- **Especificação Formal do Protocolo:** [`docs/CCSP.md`](./CCSP.md)
|
||||
- **JSON Schema CCSP/1:** [`docs/ccsp-v1.schema.json`](./ccsp-v1.schema.json)
|
||||
- **Compatibilidade de Navegadores & PNA:** [`docs/browser-compatibility.md`](./browser-compatibility.md)
|
||||
- **Exemplos Didáticos:** [`sdk/examples/`](../sdk/examples/README.md)
|
||||
- **Processo de Release do Pacote:** [`docs/sdk-release-process.md`](./sdk-release-process.md)
|
||||
|
||||
|
||||
@@ -1,46 +1,73 @@
|
||||
# Modelo de Segurança e Privacidade
|
||||
# Modelo de Segurança, Privacidade e Análise de Ameaças (STRIDE)
|
||||
|
||||
O **CyberCore Signer** foi projetado seguindo as diretrizes rigorosas da ICP-Brasil e os princípios de *Security by Design* e *Least Privilege*.
|
||||
> **Diretrizes de Segurança em Profundidade, Proteção Criptográfica e Conformidade LGPD**
|
||||
|
||||
O **CyberCore Signer** foi concebido sob os princípios de *Security by Design* (Segurança desde a Concepção) e *Least Privilege* (Menor Privilégio), seguindo as normas da **Infraestrutura de Chaves Públicas Brasileira (ICP-Brasil)** e as exigências da **Lei Geral de Proteção de Dados (LGPD — Lei nº 13.709/2018)**.
|
||||
|
||||
---
|
||||
|
||||
## 1. Isolamento de Rede
|
||||
## 1. Modelo de Ameaças STRIDE e Matriz de Mitigações
|
||||
|
||||
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.
|
||||
| Categoria STRIDE | Ameaça Potencial | Superfície / Vetor | Mitigação Implementada no CyberCore Signer |
|
||||
|---|---|---|---|
|
||||
| **S** - *Spoofing* (Falsificação de Identidade) | Aplicação web maliciosa se passa por sistema corporativo para assinar documentos fraudulentos. | `POST /ccsp/v1/sign`, `GET /ccsp/v1/certificates` | 1. Validação estrita de `Host` loopback (`127.0.0.1` / `localhost`).<br>2. Verificação de `Origin` autorizada cadastrada no `ApplicationRegistry`.<br>3. Handshake de consentimento explícito do usuário com diálogo nativo do sistema operacional. |
|
||||
| **T** - *Tampering* (Adulteração de Dados) | Malware local modifica arquivo `authorized-apps.json` para autorizar origens maliciosas silenciosamente. | Sistema de arquivos local (`~/.cybercore/authorized-apps.json`) | 1. Assinatura de integridade por **HMAC-SHA256** (`authorized-apps.json.hmac`) com chave isolada (`.vault_key`).<br>2. Permissões POSIX restritas `0600`/`0700` (acesso exclusivo do usuário dono).<br>3. Descarte total e alerta de auditoria em caso de divergência do HMAC. |
|
||||
| **R** - *Repudiation* (Repúdio de Operações) | Usuário ou aplicação contesta a execução de uma assinatura digital. | Transações CCSP / Logs | 1. Princípio **WYSIWYS**: exibição obrigatória de origem, aplicação, nome/tamanho do documento e hash SHA-256 legível.<br>2. Logs de auditoria estruturados com `request_id` (UUID), timestamp ISO 8601, algoritmo e fingerprint do certificado (sem expor segredos). |
|
||||
| **I** - *Information Disclosure* (Vazamento de Informações) | Vazamento de chave privada, PIN do token A3 ou documentos confidenciais. | Memória, tráfego de rede e arquivos de log | 1. Chaves privadas nunca saem da máquina/hardware.<br>2. Operação primária por **Digest** (o documento confidencial nunca trafega para o agente).<br>3. PIN manipulado em `char[]` efêmero com limpeza de memória (`Arrays.fill`).<br>4. Mascaramento LGPD de CPF em carimbos visuais.<br>5. Loopback estrito `127.0.0.1` e cabeçalhos PNA (`Access-Control-Allow-Private-Network`). |
|
||||
| **D** - *Denial of Service* (Negação de Serviço) | Invasor esgota conexões locais, tenta replay de assinaturas ou trava token mono-thread. | Socket HTTP local, driver PKCS#11 | 1. Trava FIFO serializada (`signLock` ReentrantLock) para proteção de drivers A3 mono-thread.<br>2. `NonceManager` com expiração TTL, tolerância a clock skew (30s) e deduplicação atômica.<br>3. Detecção e tratamento de colisão de porta com mecanismo de **Port-Hunting** sequencial. |
|
||||
| **E** - *Elevation of Privilege* (Elevação de Privilégio) | Usuário ou script tenta forçar modo de desenvolvimento (`--dev`) em ambiente de produção para simular assinaturas. | Linha de comando / Flags de execução | 1. Trava inviolável no bootstrap (`CyberCoreSignerApplication`): compilações com `flavor=production` abortam imediatamente com `SecurityException` se `--dev` for fornecido.<br>2. O modo dev só pode ser compilado através do perfil Maven explícito `-Pdev`. |
|
||||
|
||||
---
|
||||
|
||||
## 2. Autenticação e Autorização de Aplicações
|
||||
## 2. Comunicação Local e Arquitetura de Transporte (HTTP vs WebSocket)
|
||||
|
||||
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.
|
||||
### 2.1 Por que HTTP REST Loopback em vez de WebSockets?
|
||||
O CyberCore Signer utiliza **HTTP/1.1 REST Loopback** em `127.0.0.1:21325` por decisões fundamentadas de segurança:
|
||||
1. **Mitigação de CSWSH (Cross-Site WebSocket Hijacking):** WebSockets não respeitam a política Same-Origin Policy (SOP) tradicional e ignoram restrições CORS padrão de preflight `OPTIONS`. Um script malicioso em qualquer aba poderia abrir conexões bidirecionais diretas sem controle prévio.
|
||||
2. **Compatibilidade com Private Network Access (PNA):** Navegadores modernos (Chromium / Firefox) impõem restrições rigorosas quando páginas web públicas acessam `127.0.0.1`. O protocolo HTTP REST permite responder ao preflight com `Access-Control-Allow-Private-Network: true`, atendendo integralmente às diretrizes do W3C.
|
||||
3. **Preflight CORS Determinístico:** Cada método sensível é precedido por um preflight que valida o `Origin` contra o registro de aplicações autorizadas antes que qualquer payload seja consumido.
|
||||
|
||||
---
|
||||
|
||||
## 3. Proteção Contra Ataques de Replay
|
||||
## 3. Isolamento de Rede e Proteção Local
|
||||
|
||||
Cada solicitação de assinatura enviada pelo SDK deve incluir:
|
||||
- Um `nonce` (UUID criptograficamente aleatório);
|
||||
- Um campo `expires_at` (ou `timestamp`).
|
||||
### 3.1 Ligação Estrita ao Loopback (`127.0.0.1`)
|
||||
O servidor HTTP do protocolo CCSP/1 vincula-se exclusivamente ao endereço IPv4 local `127.0.0.1`.
|
||||
* **Sem Escuta Externa:** O agente nunca realiza bind em `0.0.0.0` ou em interfaces de rede de área local (LAN) ou externa (WAN).
|
||||
* **Bloqueio no Socket:** Tentativas de acesso originadas fora da própria estação de trabalho são sumariamente recusadas pelo subsistema de rede do sistema operacional antes de qualquer processamento da aplicação.
|
||||
|
||||
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.
|
||||
### 3.2 Proteção Contra Ataques de DNS Rebinding
|
||||
Para neutralizar ataques em que domínios externos tentam resolver para `127.0.0.1` e burlar a política de mesma origem do navegador:
|
||||
* O `CcspServer` valida expressamente o cabeçalho HTTP `Host` em **todas** as requisições (incluindo preflight `OPTIONS`).
|
||||
* Somente requisições com `Host` correspondente a `127.0.0.1:{porta}`, `localhost:{porta}` ou `[::1]:{porta}` são aceitas; qualquer outro valor é rejeitado com status `403 Forbidden` (`INVALID_ORIGIN`).
|
||||
|
||||
---
|
||||
|
||||
## 4. Confirmação Visual Obrigatória e Proteção de PIN/Chaves
|
||||
## 4. Integridade Criptográfica do Registro de Aplicações (`ApplicationRegistry`)
|
||||
|
||||
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.
|
||||
As aplicações autorizadas pelo usuário são armazenadas em disco sob um esquema de proteção em profundidade:
|
||||
1. **Caminho:** `~/.cybercore/authorized-apps.json`.
|
||||
2. **Assinatura HMAC-SHA256:** Arquivo companheiro `authorized-apps.json.hmac` calcula o HMAC dos bytes JSON usando chave mestre de 256 bits gerada via `SecureRandom` e armazenada em `~/.cybercore/.vault_key`.
|
||||
3. **Permissões de Arquivo:** Aplicação de restrições POSIX (`0600` para arquivos e `0700` para diretórios) garantindo que apenas o usuário logado no sistema operacional possua direitos de leitura e escrita.
|
||||
4. **Detecção de Adulteração:** Se o arquivo JSON for modificado externamente (por malware ou editor de texto) sem a chave mestre, o carregamento descarta 100% das autorizações e emite alerta crítico de auditoria.
|
||||
|
||||
---
|
||||
|
||||
## 5. Proteção de Memória e Ciclo de Vida do PIN/Chaves
|
||||
|
||||
1. **Assinatura Exclusivamente Assistida:** Toda operação de assinatura exige interação humana voluntária. O agente nunca assina documentos em segundo plano de forma desassistida via API.
|
||||
2. **Inviolabilidade da Chave Privada:** As chaves privadas ICP-Brasil nunca são exportadas em formato de bytes brutos. Operações em certificados A3 utilizam handles nativos do hardware/middleware (PKCS#11 / MSCAPI).
|
||||
3. **Higienização de Memória Volátil:**
|
||||
- O PIN de smartcards e senhas de certificados A1 nunca são convertidos para strings imutáveis do Java (`java.lang.String`).
|
||||
- São manipulados estritamente como arrays de caracteres mutáveis `char[]`.
|
||||
- Imediatamente após a autenticação do token, o array é sobrescrito com zeros via `Arrays.fill(pinBuffer, (char) 0)`.
|
||||
4. **Remoção de Hardware (`TOKEN_REMOVED`):** Se o usuário desconectar o token USB ou retirar o cartão do leitor, o `SmartCardWatcher` força a limpeza imediata do cache de sessão e dos buffers do sistema operacional.
|
||||
|
||||
---
|
||||
|
||||
## 6. Software Bill of Materials (SBOM) e Cadeia de Suprimentos
|
||||
|
||||
1. **CycloneDX SBOM:** O build padronizado do projeto gera automaticamente o SBOM nos formatos `target/bom.json` e `target/bom.xml` conforme a especificação CycloneDX 1.6.
|
||||
2. **Análise Estática e Vulnerabilidades:** O pipeline de CI executa verificações contínuas de segurança:
|
||||
- **SpotBugs + FindSecBugs:** Análise estática profunda de vulnerabilidades criptográficas e injeções de código.
|
||||
- **Enforcer Plugin:** Verificação rigorosa de versões mínimas do compilador e convergência de dependências transitivas.
|
||||
|
||||
@@ -30,24 +30,18 @@ Em conformidade com os requisitos do projeto, a interface **não é uma cópia d
|
||||
|
||||
---
|
||||
|
||||
## 3. Grade Central de Ações (Hub 4x3)
|
||||
## 3. Grade Central de Ações (Hub 3x2)
|
||||
|
||||
A interface principal organiza as operações em 12 cartões modernos com cantos arredondados (8px), sombras suaves e ícones vetoriais SVG nítidos:
|
||||
A interface principal organiza as operações em 6 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. |
|
||||
| (2, 0) | **Assinatura Atachada** | Assinatura embutida/anexada ao documento original (CAdES enveloped). |
|
||||
| (0, 1) | **Gerenciar Selos** | Abertura do Designer e Gerenciador de Selos Digitais visuais para PAdES. |
|
||||
| (1, 1) | **Validar Assinatura** | Verificação de integridade criptográfica e conformidade do certificado digital. |
|
||||
| (2, 1) | **Sair do Agente** | Encerramento seguro da sessão do agente. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# Guia de Padrão Visual e Design System SVG — CyberCore Signer
|
||||
|
||||
Este documento estabelece a especificação oficial de design, geometria, tokens de cores e regras de compatibilidade para todos os ícones e elementos gráficos vetoriais (**SVG**) do ecossistema **CyberCore Signer**.
|
||||
|
||||
---
|
||||
|
||||
## 1. Princípios de Design
|
||||
|
||||
1. **Clareza e Precisão:** Cada ícone deve comunicar imediatamente sua função técnica (PDF, XML, assinatura embutida, validação, credenciais).
|
||||
2. **Minimalismo e Geometria Pura:** Evitar ruídos visuais, nós desnecessários, filtros proprietários ou imagens bitmap embutidas (`<image>`).
|
||||
3. **Consistência de Traço e Escala:** Espessuras de contorno previsíveis e alinhadas ao grid de 48x48 pixels.
|
||||
4. **Interoperabilidade:** Totalmente compatível com editores vetoriais modernos (**Inkscape, Figma, Adobe Illustrator**) e com o parser nativo de JavaFX em `AssetManager`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Grid e Canvas Canônico
|
||||
|
||||
| Propriedade | Valor Padrão | Descrição |
|
||||
|---|---|---|
|
||||
| **ViewBox** | `0 0 48 48` | Padrão obrigatório para ícones da grade de ações e ferramentas laterais. |
|
||||
| **Dimensões Base** | `width="48" height="48"` | Dimensões nominais 1:1. |
|
||||
| **Área Segura** | `4px` de margem interna | O conteúdo vetorial deve se concentrar entre `(4, 4)` e `(44, 44)`. |
|
||||
| **Raio de Canto Padrão** | `rx="3"` ou `rx="6"` | Arredondamentos harmônicos em cantos de janelas e etiquetas. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Paleta de Cores e Tokens Visuais
|
||||
|
||||
Os ícones utilizam uma paleta de cores corporativa baseada em Material Design e Tailwind Modern, com alto contraste sobre fundos claros e escuros:
|
||||
|
||||
| Token | Hexadecimal | Papel / Significado |
|
||||
|---|---|---|
|
||||
| **`color-brand-primary`** | `#0288d1` | Azul CyberCore. Traço padrão de folhas, botões principais e XML. |
|
||||
| **`color-brand-deep`** | `#01579b` | Azul escuro para dobras e acentos de profundidade. |
|
||||
| **`color-paper-fold`** | `#b3e5fc` | Preenchimento suave da dobra superior do documento padrão. |
|
||||
| **`color-pdf-accent`** | `#d32f2f` | Vermelho institucional do formato Adobe PDF. |
|
||||
| **`color-pdf-fold`** | `#ffcdd2` | Preenchimento suave da dobra do documento PDF. |
|
||||
| **`color-success`** | `#10b981` / `#2e7d32` | Verde esmeralda para assinaturas válidas e verificações. |
|
||||
| **`color-gold-seal`** | `#f59e0b` / `#d97706` | Dourado e âmbar para selos notariais e certificação digital. |
|
||||
| **`color-paper-lines`** | `#b0bec5` / `#cfd8dc` | Linhas de texto representativas e divisórias neutras. |
|
||||
| **`color-console-bg`** | `#1e293b` | Fundo de janelas de terminal (Logs). |
|
||||
| **`color-danger-exit`** | `#d32f2f` | Seta e alertas de encerramento do agente. |
|
||||
|
||||
---
|
||||
|
||||
## 4. Anatomia dos Ícones de Documentos
|
||||
|
||||
Para documentos com dobra (como PDF, XML, Atachada e Validador):
|
||||
|
||||
```xml
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
|
||||
<!-- 1. Folha de documento com dobra -->
|
||||
<path d="M 8 4 L 32 4 L 42 14 L 42 44 L 8 44 Z"
|
||||
fill="#ffffff" stroke="[COR_BORDA]" stroke-width="2.5" stroke-linejoin="round"/>
|
||||
|
||||
<!-- 2. Dobra de canto superior direito -->
|
||||
<path d="M 32 4 L 32 14 L 42 14 Z"
|
||||
fill="[COR_FUNDO_DOBRA]" stroke="[COR_BORDA]" stroke-width="2" stroke-linejoin="round"/>
|
||||
|
||||
<!-- 3. Linhas de texto representativas -->
|
||||
<line x1="13" y1="18" x2="27" y2="18" stroke="#b0bec5" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="13" y1="22" x2="35" y2="22" stroke="#b0bec5" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="13" y1="26" x2="35" y2="26" stroke="#b0bec5" stroke-width="2" stroke-linecap="round"/>
|
||||
|
||||
<!-- 4. Elemento distintivo inferior (Etiqueta, Caneta, Clipe ou Checkmark) -->
|
||||
<rect x="11" y="31" width="28" height="11" rx="3" fill="[COR_BADGE]"/>
|
||||
<text x="25" y="39" font-family="'Segoe UI', Arial, sans-serif"
|
||||
font-size="8px" font-weight="bold" fill="#ffffff" text-anchor="middle">[TEXTO]</text>
|
||||
</svg>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Boas Práticas para Exportação no Inkscape / Figma
|
||||
|
||||
Ao criar ou editar ícones nas ferramentas vetoriais externas:
|
||||
|
||||
1. **Escolha a opção "SVG Simples" (Plain SVG) no Inkscape:**
|
||||
- No Inkscape: `Arquivo → Salvar Como... → SVG Simples (*.svg)`.
|
||||
- Isso elimina metadados de sessão (`sodipodi:namedview`, guias, zoom e histórico).
|
||||
2. **Nunca incorpore imagens bitmap (`<image>` base64):**
|
||||
- O aplicativo desktop deve conter exclusivamente geometria vetorial limpa (`path`, `rect`, `circle`, `line`, `polygon`, `polyline`, `text`).
|
||||
3. **Suporte a `style="..."` inline:**
|
||||
- O parser do CyberCore Signer (`AssetManager`) suporta tanto atributos XML puros (`fill="#..."`) quanto estilos inline em CSS (`style="fill:#...;stroke:#..."`).
|
||||
4. **Mantenha o ViewBox Quadrado:**
|
||||
- Preserve sempre `viewBox="0 0 48 48"` para garantir alinhamento perfeito nos botões e painéis da interface.
|
||||
@@ -1,16 +1,27 @@
|
||||
# Assinaturas XML (XMLDSig e ICP-Brasil)
|
||||
# Assinaturas XML no Padrão XAdES 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.
|
||||
Este documento descreve a especificação, conformidade e o funcionamento da assinatura digital de arquivos XML no padrão avançado **XAdES (XML Advanced Electronic Signatures - ETSI TS 101 903 e ICP-Brasil DOC-ICP-15.03)** e **W3C XMLDSig** 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`).
|
||||
O CyberCore Signer fornece suporte nativo a assinaturas digitais XML utilizando a engine padronizada e segura da plataforma Java (`javax.xml.crypto.dsig`), garantindo conformidade com o padrão **XAdES-BES** adotado pelo ITI (Instituto Nacional de Tecnologia da Informação) na infraestrutura ICP-Brasil (perfil AD-RB).
|
||||
|
||||
### 1.1 Casos de Uso Suportados
|
||||
### 1.1 Perfis Suportados
|
||||
|
||||
- **Documento Inteiro Envelopado (`URI=""`):** Contratos, despachos, declarações e relatórios em formato XML.
|
||||
* **`XAdES-BES` (Padrão Recomendado / ICP-Brasil):**
|
||||
- Conforme especificação técnica **ETSI TS 101 903** e norma **DOC-ICP-15.03**.
|
||||
- Encapsula o elemento `<ds:Object>` contendo `<xades:QualifyingProperties>` e `<xades:SignedProperties>`.
|
||||
- Inclui propriedade qualificada de data/hora da assinatura (`<xades:SigningTime>`) em formato UTC ISO-8601.
|
||||
- Inclui vínculo criptográfico irrefutável do certificado do signatário (`<xades:SigningCertificate>`) contendo o digest SHA-256 do certificado DER e referências do emissor/serial (`<xades:IssuerSerial>`).
|
||||
- Protege as propriedades qualificadas através de uma `<ds:Reference>` em `<ds:SignedInfo>` com `Type="http://uri.etsi.org/01903#SignedProperties"`.
|
||||
* **`XMLDSig` Clássico (W3C Enveloped):**
|
||||
- Mantido para compatibilidade com layouts estritos de notas fiscais eletrônicas legadas (ex: NF-e, CT-e, MDF-e SEFAZ).
|
||||
|
||||
### 1.2 Casos de Uso Suportados
|
||||
|
||||
- **Documento Inteiro Envelopado (`URI=""`):** Contratos, despachos, declarações, laudos e relatórios em XML.
|
||||
- **Assinatura de Elementos Específicos por ID:**
|
||||
- **NF-e (Nota Fiscal Eletrônica):** Assinatura da tag `<infNFe Id="NFe...">`
|
||||
- **CT-e (Conhecimento de Transporte Eletrônico):** Assinatura da tag `<infCte Id="CTe...">`
|
||||
@@ -19,19 +30,69 @@ O CyberCore Signer fornece suporte nativo a assinaturas digitais XML utilizando
|
||||
|
||||
---
|
||||
|
||||
## 2. Padrões Criptográficos
|
||||
## 2. Estrutura Criptográfica do XAdES-BES
|
||||
|
||||
- **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 `<X509Data><X509Certificate>`.
|
||||
```xml
|
||||
<ds:Signature Id="sig-[UUID]" xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
|
||||
<ds:SignedInfo>
|
||||
<ds:CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/>
|
||||
<ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>
|
||||
|
||||
<!-- Referência 0: Dados de Negócio (Documento completo ou elemento específico) -->
|
||||
<ds:Reference URI="#[ElementoId]">
|
||||
<ds:Transforms>
|
||||
<ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/>
|
||||
<ds:Transform Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/>
|
||||
</ds:Transforms>
|
||||
<ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
|
||||
<ds:DigestValue>[Digest do nó em Base64]</ds:DigestValue>
|
||||
</ds:Reference>
|
||||
|
||||
<!-- Referência 1: Propriedades Qualificadas Assinadas XAdES -->
|
||||
<ds:Reference URI="#signed-props-[UUID]" Type="http://uri.etsi.org/01903#SignedProperties">
|
||||
<ds:Transforms>
|
||||
<ds:Transform Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/>
|
||||
</ds:Transforms>
|
||||
<ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
|
||||
<ds:DigestValue>[Digest das SignedProperties em Base64]</ds:DigestValue>
|
||||
</ds:Reference>
|
||||
</ds:SignedInfo>
|
||||
|
||||
<ds:SignatureValue>[Assinatura RSA-SHA256 em Base64]</ds:SignatureValue>
|
||||
|
||||
<ds:KeyInfo>
|
||||
<ds:X509Data>
|
||||
<ds:X509Certificate>[Certificado DER do signatário em Base64]</ds:X509Certificate>
|
||||
</ds:X509Data>
|
||||
</ds:KeyInfo>
|
||||
|
||||
<ds:Object>
|
||||
<xades:QualifyingProperties xmlns:xades="http://uri.etsi.org/01903/v1.3.2#" Target="#sig-[UUID]">
|
||||
<xades:SignedProperties Id="signed-props-[UUID]" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" xmlns:xades="http://uri.etsi.org/01903/v1.3.2#">
|
||||
<xades:SignedSignatureProperties>
|
||||
<xades:SigningTime>2026-10-11T14:05:00Z</xades:SigningTime>
|
||||
<xades:SigningCertificate>
|
||||
<xades:Cert>
|
||||
<xades:CertDigest>
|
||||
<ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
|
||||
<ds:DigestValue>[SHA-256 do Certificado]</ds:DigestValue>
|
||||
</xades:CertDigest>
|
||||
<xades:IssuerSerial>
|
||||
<ds:X509IssuerName>[Emissor X.500]</ds:X509IssuerName>
|
||||
<ds:X509SerialNumber>[Número Serial]</ds:X509SerialNumber>
|
||||
</xades:IssuerSerial>
|
||||
</xades:Cert>
|
||||
</xades:SigningCertificate>
|
||||
</xades:SignedSignatureProperties>
|
||||
</xades:SignedProperties>
|
||||
</xades:QualifyingProperties>
|
||||
</ds:Object>
|
||||
</ds:Signature>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Segurança e Mitigação contra XXE
|
||||
## 3. Segurança e Mitigação contra Ataques XXE
|
||||
|
||||
O parser XML é configurado com blindagem estrita contra ataques **XML External Entity (XXE)** e expansões recursivas de entidades:
|
||||
|
||||
@@ -47,10 +108,35 @@ dbf.setExpandEntityReferences(false);
|
||||
|
||||
---
|
||||
|
||||
## 4. Validação Pós-Assinatura
|
||||
## 4. Validação Pós-Assinatura e Auditoria
|
||||
|
||||
O serviço `XmlSigningService.validateXmlSignature(byte[] xmlBytes)` permite auditar a assinatura imediatamente após sua emissão, verificando:
|
||||
O serviço `XmlSigningService.validateXmlSignature(byte[] xmlBytes)` e o `SignatureValidationService` realizam:
|
||||
|
||||
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.
|
||||
1. **Validação criptográfica de SignedInfo:** Confere se o `SignatureValue` foi emitido pela chave pública correspondente.
|
||||
2. **Validação de integridade dos dados de negócio:** Confere o digest do conteúdo ou elemento assinado.
|
||||
3. **Validação de integridade do XAdES:** Confere o digest das propriedades qualificadas (`SignedProperties`), rejeitando qualquer adulteração no horário de assinatura (`SigningTime`) ou no certificado declarado.
|
||||
4. **Inspeção de signatário e datação:** Extração de nome, documento (CPF/CNPJ), autoridade certificadora emissora e datação auditável.
|
||||
|
||||
---
|
||||
|
||||
## 5. Integração Web e SDK (@cybercore/signer-sdk)
|
||||
|
||||
As aplicações web autorizadas podem assinar arquivos XML diretamente via protocolo CCSP/1 com o método `signXmlDocument()`:
|
||||
|
||||
```javascript
|
||||
import { CyberCoreSigner } from '@cybercore/signer-sdk';
|
||||
|
||||
const signer = new CyberCoreSigner();
|
||||
|
||||
// Assinatura de documento XML ou evento fiscal
|
||||
const result = await signer.signXmlDocument({
|
||||
certificateId: cert.id,
|
||||
xml: xmlString, // texto puro ou xml_base64
|
||||
profile: 'XADES_BES', // ou 'XMLDSIG'
|
||||
targetElementTag: 'infNFe', // opcional
|
||||
targetElementId: 'NFe35261012345678000199550010000001001234567890' // opcional
|
||||
});
|
||||
|
||||
console.log('XML Assinado Retornado:', result.signed_xml);
|
||||
console.log('Algoritmo Utilizado:', result.algorithm);
|
||||
```
|
||||
|
||||
@@ -108,6 +108,23 @@
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<resources>
|
||||
<resource>
|
||||
<directory>src/main/resources</directory>
|
||||
<filtering>true</filtering>
|
||||
<includes>
|
||||
<include>build-info.properties</include>
|
||||
</includes>
|
||||
</resource>
|
||||
<resource>
|
||||
<directory>src/main/resources</directory>
|
||||
<filtering>false</filtering>
|
||||
<excludes>
|
||||
<exclude>build-info.properties</exclude>
|
||||
</excludes>
|
||||
</resource>
|
||||
</resources>
|
||||
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.openjfx</groupId>
|
||||
@@ -138,6 +155,7 @@
|
||||
<Implementation-Title>CyberCore Signer</Implementation-Title>
|
||||
<Implementation-Version>${project.version}</Implementation-Version>
|
||||
<Implementation-Vendor>CyberCore</Implementation-Vendor>
|
||||
<Build-Flavor>${cybercore.build.flavor}</Build-Flavor>
|
||||
</manifestEntries>
|
||||
</transformer>
|
||||
</transformers>
|
||||
@@ -157,6 +175,39 @@
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
|
||||
<plugin>
|
||||
<groupId>org.jacoco</groupId>
|
||||
<artifactId>jacoco-maven-plugin</artifactId>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>jacoco-check</id>
|
||||
<phase>verify</phase>
|
||||
<goals>
|
||||
<goal>check</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<rules>
|
||||
<rule>
|
||||
<element>BUNDLE</element>
|
||||
<limits>
|
||||
<limit>
|
||||
<counter>LINE</counter>
|
||||
<value>COVEREDRATIO</value>
|
||||
<minimum>0.70</minimum>
|
||||
</limit>
|
||||
<limit>
|
||||
<counter>BRANCH</counter>
|
||||
<value>COVEREDRATIO</value>
|
||||
<minimum>0.45</minimum>
|
||||
</limit>
|
||||
</limits>
|
||||
</rule>
|
||||
</rules>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
|
||||
@@ -19,15 +19,47 @@ public class CyberCoreSignerApplication {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(CyberCoreSignerApplication.class);
|
||||
|
||||
private static final String BUILD_INFO_RESOURCE = "/build-info.properties";
|
||||
private static String buildFlavor = "production";
|
||||
private static boolean developmentMode = false;
|
||||
private static boolean headlessMode = false;
|
||||
private static boolean noWaitMode = false;
|
||||
private static int configuredPort = CcspServer.DEFAULT_PORT;
|
||||
private static volatile CcspServer runningServer = null;
|
||||
|
||||
static {
|
||||
loadBuildInfo();
|
||||
}
|
||||
|
||||
private static void loadBuildInfo() {
|
||||
try (var is = CyberCoreSignerApplication.class.getResourceAsStream(BUILD_INFO_RESOURCE)) {
|
||||
if (is != null) {
|
||||
var props = new java.util.Properties();
|
||||
props.load(is);
|
||||
String flavor = props.getProperty("build.flavor");
|
||||
if (flavor != null && !flavor.isBlank()) {
|
||||
buildFlavor = flavor.trim().toLowerCase();
|
||||
}
|
||||
}
|
||||
} catch (java.io.IOException ignored) {}
|
||||
}
|
||||
|
||||
public static String getBuildFlavor() {
|
||||
return buildFlavor;
|
||||
}
|
||||
|
||||
public static void setBuildFlavorForTesting(String flavor) {
|
||||
buildFlavor = flavor;
|
||||
}
|
||||
|
||||
public static CcspServer getRunningServer() {
|
||||
return runningServer;
|
||||
}
|
||||
|
||||
public static int getConfiguredPort() {
|
||||
return configuredPort;
|
||||
}
|
||||
|
||||
public static void stopServer() {
|
||||
if (runningServer != null) {
|
||||
runningServer.stop();
|
||||
@@ -36,6 +68,17 @@ public class CyberCoreSignerApplication {
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
// Previne conflitos nativos de UI Automation/Acessibilidade do Glass no Windows 11
|
||||
if (System.getProperty("javafx.accessibility") == null) {
|
||||
System.setProperty("javafx.accessibility", "false");
|
||||
}
|
||||
if (System.getProperty("glass.accessible.force") == null) {
|
||||
System.setProperty("glass.accessible.force", "false");
|
||||
}
|
||||
Thread.setDefaultUncaughtExceptionHandler((thread, throwable) -> {
|
||||
logger.error("Exceção não tratada na thread '{}': {}", thread.getName(), throwable.getMessage(), throwable);
|
||||
});
|
||||
|
||||
parseArguments(args);
|
||||
printBanner();
|
||||
|
||||
@@ -57,34 +100,18 @@ public class CyberCoreSignerApplication {
|
||||
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();
|
||||
}
|
||||
// 1. Inicializa o núcleo e o servidor CCSP imediatamente (alta velocidade)
|
||||
CertificateManager certManager = CertificateManager.createDefault();
|
||||
if (developmentMode) {
|
||||
logger.info("Modo de desenvolvimento ativo (com certificados reais do sistema).");
|
||||
}
|
||||
SigningService signingService = new SigningService(certManager);
|
||||
br.com.cybercore.signer.core.protocol.CcspUserPromptListener promptListener =
|
||||
new br.com.cybercore.signer.ui.prompt.JavaFxPromptListener();
|
||||
|
||||
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 = new CcspServer(configuredPort, certManager, signingService,
|
||||
br.com.cybercore.signer.core.security.ApplicationRegistry.getInstance(), promptListener);
|
||||
runningServer.start();
|
||||
logger.info("Servidor local CCSP/1 pronto em http://127.0.0.1:{}", runningServer.getPort());
|
||||
|
||||
@@ -92,22 +119,74 @@ public class CyberCoreSignerApplication {
|
||||
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 (java.net.BindException e) {
|
||||
logger.error("ALERTA DE COLISÃO DE PORTA: A porta {} já está em uso por outra instância do CyberCore Signer ou outro serviço. Para usar uma porta alternativa, utilize o parâmetro --port <porta> ou defina a variável de ambiente CYBERCORE_PORT.", configuredPort);
|
||||
} catch (Exception e) {
|
||||
logger.error("Falha ao inicializar servidor local CCSP/1 no modo console: {}", e.getMessage(), e);
|
||||
if (e.getCause() instanceof java.net.BindException || (e.getMessage() != null && e.getMessage().contains("Address already in use"))) {
|
||||
logger.error("ALERTA DE COLISÃO DE PORTA: A porta {} já está em uso por outra instância do CyberCore Signer ou outro serviço. Para usar uma porta alternativa, utilize o parâmetro --port <porta> ou defina a variável de ambiente CYBERCORE_PORT.", configuredPort);
|
||||
} else {
|
||||
logger.error("Falha ao inicializar servidor local CCSP/1: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 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) {
|
||||
try {
|
||||
logger.info("Iniciando interface gráfica desktop desacoplada...");
|
||||
CyberCoreDesktopApplication.setDevelopmentMode(developmentMode);
|
||||
CyberCoreDesktopApplication.setSharedServices(runningServer, certManager, signingService);
|
||||
Thread uiThread = new Thread(() -> {
|
||||
try {
|
||||
Application.launch(CyberCoreDesktopApplication.class, args);
|
||||
} catch (Throwable t) {
|
||||
logger.warn("Interface gráfica finalizada ou indisponível: {}", t.getMessage());
|
||||
}
|
||||
}, "cybercore-desktop-ui");
|
||||
uiThread.setDaemon(false);
|
||||
uiThread.start();
|
||||
} catch (Throwable t) {
|
||||
logger.warn("Não foi possível inicializar ambiente gráfico ({}). Continuando em modo serviço.", t.getMessage());
|
||||
}
|
||||
} else {
|
||||
logger.info("Executando em modo console / headless (sem interface gráfica).");
|
||||
}
|
||||
|
||||
// Mantém processo vivo para atender requisições web, a menos que em modo no-wait
|
||||
if (!noWaitMode) {
|
||||
try {
|
||||
Thread.currentThread().join();
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("CyberCore Signer finalizado.");
|
||||
}
|
||||
|
||||
private static void parseArguments(String[] args) {
|
||||
public static void parseArguments(String[] args) {
|
||||
String envPort = System.getenv("CYBERCORE_PORT");
|
||||
if (envPort != null && !envPort.isBlank()) {
|
||||
try {
|
||||
configuredPort = Integer.parseInt(envPort.trim());
|
||||
} catch (NumberFormatException ignored) {}
|
||||
}
|
||||
String propPort = System.getProperty("cybercore.port");
|
||||
if (propPort != null && !propPort.isBlank()) {
|
||||
try {
|
||||
configuredPort = Integer.parseInt(propPort.trim());
|
||||
} catch (NumberFormatException ignored) {}
|
||||
}
|
||||
|
||||
if (args != null) {
|
||||
for (String arg : args) {
|
||||
for (int i = 0; i < args.length; i++) {
|
||||
String arg = args[i];
|
||||
if ("--dev".equalsIgnoreCase(arg) || "-dev".equalsIgnoreCase(arg)) {
|
||||
if ("production".equalsIgnoreCase(buildFlavor)) {
|
||||
logger.error("ALERTA DE SEGURANÇA: Modo de desenvolvimento (--dev) não é permitido em compilações de produção (flavor: production).");
|
||||
throw new SecurityException("Modo de desenvolvimento (--dev) bloqueado em compilações de produção.");
|
||||
}
|
||||
developmentMode = true;
|
||||
} else if ("--headless".equalsIgnoreCase(arg) || "-headless".equalsIgnoreCase(arg)
|
||||
|| "--cli".equalsIgnoreCase(arg)) {
|
||||
@@ -115,6 +194,13 @@ public class CyberCoreSignerApplication {
|
||||
} else if ("--no-wait".equalsIgnoreCase(arg) || "-no-wait".equalsIgnoreCase(arg)
|
||||
|| "--test".equalsIgnoreCase(arg)) {
|
||||
noWaitMode = true;
|
||||
} else if (("--port".equalsIgnoreCase(arg) || "-p".equalsIgnoreCase(arg)) && i + 1 < args.length) {
|
||||
try {
|
||||
configuredPort = Integer.parseInt(args[++i].trim());
|
||||
} catch (NumberFormatException e) {
|
||||
String sanitized = args[i].replaceAll("[\r\n]", "");
|
||||
logger.warn("Porta inválida especificada em --port: '{}'. Usando porta padrão {}.", sanitized, CcspServer.DEFAULT_PORT);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,12 +217,12 @@ public class CyberCoreSignerApplication {
|
||||
private static void printBanner() {
|
||||
System.out.println("""
|
||||
============================================================
|
||||
____ _ ____
|
||||
/ ___| _ _ __| | ___ _ __ / ___|___ _ __ ___
|
||||
| | | | | |/ _` | / _ \\| '__| | | / _ \\ | '__|/ _ \\
|
||||
| |___ | |_| | (_| || __/| | | |__| (_) || | | __/
|
||||
\\____| \\__, |\\__,_| \\___||_| \\____\\___/ |_| \\___|
|
||||
|___/
|
||||
____ _ ____ \s
|
||||
/ ___| _ _ | |__ ___ _ __ / ___|___ _ __ ___ \s
|
||||
| | | | | | | '_ \\ / _ \\ '__| | | / _ \\| '__/ _ \\ \s
|
||||
| |___ | |_| | | |_) | __/ | | |__| (_) | | | __/ \s
|
||||
\\____| \\__, | |_.__/ \\___|_| \\____\\___/|_| \\___| \s
|
||||
|___/ \s
|
||||
SIGNER AGENT v0.1.0
|
||||
============================================================
|
||||
""");
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package br.com.cybercore.signer.app.log;
|
||||
|
||||
import br.com.cybercore.signer.core.log.AppLogBuffer;
|
||||
import ch.qos.logback.classic.spi.ILoggingEvent;
|
||||
import ch.qos.logback.core.AppenderBase;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
/**
|
||||
* Appender do Logback que intercepta eventos reais de log e os encaminha para o buffer em memória.
|
||||
*/
|
||||
public class InMemoryLogAppender extends AppenderBase<ILoggingEvent> {
|
||||
|
||||
private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");
|
||||
|
||||
@Override
|
||||
protected void append(ILoggingEvent eventObject) {
|
||||
if (eventObject == null) return;
|
||||
|
||||
LocalDateTime time = LocalDateTime.ofInstant(
|
||||
Instant.ofEpochMilli(eventObject.getTimeStamp()),
|
||||
ZoneId.systemDefault()
|
||||
);
|
||||
String timestamp = time.format(FORMATTER);
|
||||
String level = eventObject.getLevel().toString();
|
||||
String loggerName = eventObject.getLoggerName();
|
||||
if (loggerName != null && loggerName.length() > 30) {
|
||||
int lastDot = loggerName.lastIndexOf('.');
|
||||
if (lastDot > 0) {
|
||||
loggerName = loggerName.substring(lastDot + 1);
|
||||
}
|
||||
}
|
||||
String message = eventObject.getFormattedMessage();
|
||||
|
||||
String line = String.format("[%s] [%s] %s - %s", timestamp, level, loggerName, message);
|
||||
AppLogBuffer.append(line);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
build.version=${project.version}
|
||||
build.flavor=${cybercore.build.flavor}
|
||||
@@ -6,8 +6,11 @@
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<appender name="MEMORY" class="br.com.cybercore.signer.app.log.InMemoryLogAppender" />
|
||||
|
||||
<root level="INFO">
|
||||
<appender-ref ref="CONSOLE" />
|
||||
<appender-ref ref="MEMORY" />
|
||||
</root>
|
||||
|
||||
<logger name="br.com.cybercore.signer" level="DEBUG" />
|
||||
|
||||
@@ -34,4 +34,17 @@ class ArchitectureTest {
|
||||
|
||||
rule.check(importedClasses);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("O módulo Core não deve acessar o ciclo de vida do toolkit JavaFX (Application ou Platform)")
|
||||
void coreShouldNotAccessJavaFxLifecycle() {
|
||||
ArchRule rule = noClasses()
|
||||
.that().resideInAPackage("br.com.cybercore.signer.core..")
|
||||
.should().dependOnClassesThat()
|
||||
.haveFullyQualifiedName("javafx.application.Application")
|
||||
.orShould().dependOnClassesThat()
|
||||
.haveFullyQualifiedName("javafx.application.Platform");
|
||||
|
||||
rule.check(importedClasses);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,13 +8,43 @@ import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
class CyberCoreSignerApplicationTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("Deve inicializar a aplicação com argumentos sem erros")
|
||||
@DisplayName("Deve inicializar a aplicação com argumentos sem erros em compilação normal")
|
||||
void shouldBootstrapWithoutErrors() {
|
||||
try {
|
||||
assertThatCode(() -> CyberCoreSignerApplication.main(new String[]{"--dev", "--headless", "--no-wait"}))
|
||||
assertThatCode(() -> CyberCoreSignerApplication.main(new String[]{"--headless", "--no-wait", "--port", "29999"}))
|
||||
.doesNotThrowAnyException();
|
||||
} finally {
|
||||
CyberCoreSignerApplication.stopServer();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Deve bloquear o modo dev em compilações de produção")
|
||||
void shouldBlockDevModeInProductionFlavor() {
|
||||
CyberCoreSignerApplication.setBuildFlavorForTesting("production");
|
||||
org.assertj.core.api.Assertions.assertThatThrownBy(() ->
|
||||
CyberCoreSignerApplication.parseArguments(new String[]{"--dev"})
|
||||
).isInstanceOf(SecurityException.class)
|
||||
.hasMessageContaining("bloqueado em compilações de produção");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Deve permitir o modo dev quando o flavor for development")
|
||||
void shouldAllowDevModeInDevelopmentFlavor() {
|
||||
CyberCoreSignerApplication.setBuildFlavorForTesting("development");
|
||||
org.assertj.core.api.Assertions.assertThatCode(() ->
|
||||
CyberCoreSignerApplication.parseArguments(new String[]{"--dev"})
|
||||
).doesNotThrowAnyException();
|
||||
org.assertj.core.api.Assertions.assertThat(CyberCoreSignerApplication.isDevelopmentMode()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Deve realizar o parse correto do argumento --port")
|
||||
void shouldParsePortArgument() {
|
||||
CyberCoreSignerApplication.parseArguments(new String[]{"--port", "25555"});
|
||||
org.assertj.core.api.Assertions.assertThat(CyberCoreSignerApplication.getConfiguredPort()).isEqualTo(25555);
|
||||
|
||||
CyberCoreSignerApplication.parseArguments(new String[]{"-p", "26666"});
|
||||
org.assertj.core.api.Assertions.assertThat(CyberCoreSignerApplication.getConfiguredPort()).isEqualTo(26666);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,4 +61,41 @@
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.jacoco</groupId>
|
||||
<artifactId>jacoco-maven-plugin</artifactId>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>jacoco-check</id>
|
||||
<phase>verify</phase>
|
||||
<goals>
|
||||
<goal>check</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<rules>
|
||||
<rule>
|
||||
<element>BUNDLE</element>
|
||||
<limits>
|
||||
<limit>
|
||||
<counter>LINE</counter>
|
||||
<value>COVEREDRATIO</value>
|
||||
<minimum>0.60</minimum>
|
||||
</limit>
|
||||
<limit>
|
||||
<counter>BRANCH</counter>
|
||||
<value>COVEREDRATIO</value>
|
||||
<minimum>0.40</minimum>
|
||||
</limit>
|
||||
</limits>
|
||||
</rule>
|
||||
</rules>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
|
||||
@@ -97,8 +97,8 @@ public class SigningService {
|
||||
if (hint != null && !hint.isBlank()) {
|
||||
Optional<ManagedCertificate> found = certificateManager.findByHint(hint);
|
||||
if (found.isEmpty()) {
|
||||
// Tenta rediscovery caso um token tenha acabado de ser conectado
|
||||
certificateManager.discoverCertificates();
|
||||
// Tenta rediscovery forçado caso um token tenha acabado de ser conectado
|
||||
certificateManager.discoverCertificates(true);
|
||||
found = certificateManager.findByHint(hint);
|
||||
}
|
||||
if (found.isPresent()) {
|
||||
@@ -113,8 +113,8 @@ public class SigningService {
|
||||
// Sem hint: seleciona automaticamente se houver apenas um certificado operacional disponível
|
||||
List<ManagedCertificate> available = certificateManager.getAvailableCertificates();
|
||||
if (available.isEmpty()) {
|
||||
// Tenta redescobrir
|
||||
certificateManager.discoverCertificates();
|
||||
// Tenta redescobrir forçando consulta aos provedores
|
||||
certificateManager.discoverCertificates(true);
|
||||
available = certificateManager.getAvailableCertificates();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package br.com.cybercore.signer.core.crypto.revocation;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Cache concorrente em memória com TTL (Time-To-Live) para respostas OCSP e listas CRL,
|
||||
* evitando requisições de rede redundantes e respeitando tempos de atualização das ACs.
|
||||
*/
|
||||
public class RevocationCache<K, V> {
|
||||
|
||||
private final ConcurrentHashMap<K, CacheEntry<V>> cache = new ConcurrentHashMap<>();
|
||||
|
||||
private record CacheEntry<V>(V value, Instant expiresAt) {
|
||||
boolean isExpired() {
|
||||
return Instant.now().isAfter(expiresAt);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Armazena um item no cache com o TTL especificado.
|
||||
*/
|
||||
public void put(K key, V value, Duration ttl) {
|
||||
Objects.requireNonNull(key, "key cannot be null");
|
||||
Objects.requireNonNull(value, "value cannot be null");
|
||||
Objects.requireNonNull(ttl, "ttl cannot be null");
|
||||
|
||||
Instant expiresAt = Instant.now().plus(ttl);
|
||||
cache.put(key, new CacheEntry<>(value, expiresAt));
|
||||
}
|
||||
|
||||
/**
|
||||
* Recupera um item do cache se ainda for válido; caso tenha expirado, remove e retorna vazio.
|
||||
*/
|
||||
public Optional<V> get(K key) {
|
||||
Objects.requireNonNull(key, "key cannot be null");
|
||||
CacheEntry<V> entry = cache.get(key);
|
||||
if (entry == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
if (entry.isExpired()) {
|
||||
cache.remove(key, entry);
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(entry.value());
|
||||
}
|
||||
|
||||
/**
|
||||
* Recupera o item em cache ou obtém um novo através do fetcher caso não exista ou tenha expirado.
|
||||
*/
|
||||
public <E extends Exception> V getOrFetch(K key, Duration ttl, ThrowingSupplier<V, E> fetcher) throws E {
|
||||
Optional<V> existing = get(key);
|
||||
if (existing.isPresent()) {
|
||||
return existing.get();
|
||||
}
|
||||
|
||||
V fetched = fetcher.get();
|
||||
if (fetched != null) {
|
||||
put(key, fetched, ttl);
|
||||
}
|
||||
return fetched;
|
||||
}
|
||||
|
||||
/**
|
||||
* Limpa todas as entradas expiradas do cache.
|
||||
*/
|
||||
public void evictExpired() {
|
||||
cache.entrySet().removeIf(entry -> entry.getValue().isExpired());
|
||||
}
|
||||
|
||||
/**
|
||||
* Limpa integralmente o cache.
|
||||
*/
|
||||
public void clear() {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retorna o número de entradas ativas ou pendentes no cache.
|
||||
*/
|
||||
public int size() {
|
||||
return cache.size();
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
public interface ThrowingSupplier<T, E extends Exception> {
|
||||
T get() throws E;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package br.com.cybercore.signer.core.crypto.tsa;
|
||||
|
||||
import br.com.cybercore.signer.core.protocol.CcspErrorCode;
|
||||
import br.com.cybercore.signer.core.provider.CertificateProviderException;
|
||||
import org.bouncycastle.asn1.ASN1ObjectIdentifier;
|
||||
import org.bouncycastle.asn1.nist.NISTObjectIdentifiers;
|
||||
import org.bouncycastle.tsp.TSPAlgorithms;
|
||||
import org.bouncycastle.tsp.TimeStampRequest;
|
||||
import org.bouncycastle.tsp.TimeStampRequestGenerator;
|
||||
import org.bouncycastle.tsp.TimeStampResponse;
|
||||
import org.bouncycastle.tsp.TimeStampToken;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigInteger;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Cliente resiliente de Carimbo do Tempo (TSA RFC 3161) com suporte a retentativas com backoff,
|
||||
* timeout configurável e fallback automático para servidores secundários.
|
||||
*/
|
||||
public final class TsaClient {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(TsaClient.class);
|
||||
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
|
||||
|
||||
private final List<String> serverUrls;
|
||||
private final Duration timeout;
|
||||
private final int maxRetries;
|
||||
private final Duration initialBackoff;
|
||||
private final HttpClient httpClient;
|
||||
|
||||
public TsaClient(List<String> serverUrls) {
|
||||
this(serverUrls, Duration.ofSeconds(5), 2, Duration.ofMillis(100), null);
|
||||
}
|
||||
|
||||
public TsaClient(List<String> serverUrls, Duration timeout, int maxRetries, Duration initialBackoff, HttpClient customHttpClient) {
|
||||
Objects.requireNonNull(serverUrls, "serverUrls cannot be null");
|
||||
if (serverUrls.isEmpty()) {
|
||||
throw new IllegalArgumentException("serverUrls cannot be empty");
|
||||
}
|
||||
this.serverUrls = Collections.unmodifiableList(new ArrayList<>(serverUrls));
|
||||
this.timeout = timeout != null ? timeout : Duration.ofSeconds(5);
|
||||
this.maxRetries = Math.max(0, maxRetries);
|
||||
this.initialBackoff = initialBackoff != null ? initialBackoff : Duration.ofMillis(100);
|
||||
this.httpClient = customHttpClient != null ? customHttpClient : HttpClient.newBuilder()
|
||||
.connectTimeout(this.timeout)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Solicita um carimbo de tempo RFC 3161 para o digest informado (SHA-256).
|
||||
*/
|
||||
public TimeStampToken requestTimestamp(byte[] digest) throws CertificateProviderException {
|
||||
Objects.requireNonNull(digest, "digest cannot be null");
|
||||
byte[] requestBytes = createTimestampRequest(digest);
|
||||
|
||||
Exception lastException = null;
|
||||
|
||||
for (String serverUrl : serverUrls) {
|
||||
String sanitizedUrl = serverUrl.replaceAll("[\r\n]", "");
|
||||
for (int attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
logger.debug("Enviando requisição RFC 3161 TSA para {} (tentativa {}/{})",
|
||||
sanitizedUrl, attempt + 1, maxRetries + 1);
|
||||
|
||||
byte[] responseBytes = executeHttpRequest(serverUrl, requestBytes);
|
||||
TimeStampResponse tsResponse = new TimeStampResponse(responseBytes);
|
||||
tsResponse.validate(new TimeStampRequest(requestBytes));
|
||||
|
||||
TimeStampToken token = tsResponse.getTimeStampToken();
|
||||
if (token == null) {
|
||||
throw new IOException("TSA retornou resposta sem token: " + tsResponse.getStatusString());
|
||||
}
|
||||
|
||||
logger.info("Carimbo de tempo RFC 3161 obtido com sucesso da TSA {}", sanitizedUrl);
|
||||
return token;
|
||||
|
||||
} catch (Exception e) {
|
||||
lastException = e;
|
||||
logger.warn("Falha ao comunicar com TSA {} (tentativa {}/{}): {}",
|
||||
sanitizedUrl, attempt + 1, maxRetries + 1, e.getMessage());
|
||||
|
||||
if (attempt < maxRetries) {
|
||||
try {
|
||||
long backoffMs = initialBackoff.toMillis() * (1L << attempt);
|
||||
Thread.sleep(backoffMs);
|
||||
} catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new CertificateProviderException(
|
||||
CcspErrorCode.COMMUNICATION_ERROR,
|
||||
"Operação de carimbo de tempo interrompida",
|
||||
ie
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
logger.warn("Esgotadas tentativas para TSA {}. Tentando próximo servidor da lista...", sanitizedUrl);
|
||||
}
|
||||
|
||||
throw new CertificateProviderException(
|
||||
CcspErrorCode.COMMUNICATION_ERROR,
|
||||
"Falha ao obter carimbo de tempo RFC 3161 de todos os servidores TSA configurados: "
|
||||
+ (lastException != null ? lastException.getMessage() : "sem resposta"),
|
||||
lastException
|
||||
);
|
||||
}
|
||||
|
||||
private byte[] createTimestampRequest(byte[] digest) {
|
||||
try {
|
||||
TimeStampRequestGenerator reqGen = new TimeStampRequestGenerator();
|
||||
reqGen.setCertReq(true);
|
||||
BigInteger nonce = BigInteger.valueOf(SECURE_RANDOM.nextLong()).abs();
|
||||
ASN1ObjectIdentifier oid = new ASN1ObjectIdentifier(TSPAlgorithms.SHA256.getId());
|
||||
TimeStampRequest req = reqGen.generate(oid, digest, nonce);
|
||||
return req.getEncoded();
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Falha ao codificar requisição RFC 3161", e);
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] executeHttpRequest(String serverUrl, byte[] requestBytes) throws IOException, InterruptedException {
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(serverUrl))
|
||||
.timeout(timeout)
|
||||
.header("Content-Type", "application/timestamp-query")
|
||||
.header("Accept", "application/timestamp-reply")
|
||||
.POST(HttpRequest.BodyPublishers.ofByteArray(requestBytes))
|
||||
.build();
|
||||
|
||||
HttpResponse<byte[]> response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
|
||||
|
||||
if (response.statusCode() != 200) {
|
||||
throw new IOException("HTTP status " + response.statusCode() + " retornado pela TSA");
|
||||
}
|
||||
|
||||
return response.body();
|
||||
}
|
||||
|
||||
public List<String> getServerUrls() {
|
||||
return serverUrls;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package br.com.cybercore.signer.core.log;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Deque;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Buffer circular thread-safe em memória para armazenar eventos reais de log da aplicação,
|
||||
* permitindo auditoria visual em tempo real pela interface gráfica desktop.
|
||||
*/
|
||||
public final class AppLogBuffer {
|
||||
|
||||
private static final int MAX_ENTRIES = 1000;
|
||||
private static final Deque<String> BUFFER = new ArrayDeque<>(MAX_ENTRIES);
|
||||
private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");
|
||||
|
||||
static {
|
||||
appendSystemMessage("CyberCore Signer iniciado. Aguardando eventos criptográficos e conexões locais...");
|
||||
}
|
||||
|
||||
private AppLogBuffer() {
|
||||
}
|
||||
|
||||
public static synchronized void append(String logLine) {
|
||||
if (logLine == null || logLine.isBlank()) return;
|
||||
if (BUFFER.size() >= MAX_ENTRIES) {
|
||||
BUFFER.pollFirst();
|
||||
}
|
||||
BUFFER.addLast(logLine.trim());
|
||||
}
|
||||
|
||||
public static synchronized void appendSystemMessage(String msg) {
|
||||
String timestamp = LocalDateTime.now().format(FORMATTER);
|
||||
append("[" + timestamp + "] [INFO] " + msg);
|
||||
}
|
||||
|
||||
public static synchronized List<String> getLogs() {
|
||||
return new ArrayList<>(BUFFER);
|
||||
}
|
||||
|
||||
public static synchronized void clear() {
|
||||
BUFFER.clear();
|
||||
appendSystemMessage("Histórico de logs limpo pelo usuário.");
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,8 @@ public record PdfSignatureOptions(
|
||||
Integer targetPageNumber,
|
||||
CpfDisplayMode cpfDisplayMode,
|
||||
Boolean includeDateTime,
|
||||
String customExtraInfo
|
||||
String customExtraInfo,
|
||||
Double customSealScale
|
||||
) {
|
||||
|
||||
public PdfSignatureOptions {
|
||||
@@ -51,6 +52,21 @@ public record PdfSignatureOptions(
|
||||
if (includeDateTime == null) {
|
||||
includeDateTime = sealTheme.defaultIncludeDateTime();
|
||||
}
|
||||
if (customSealScale == null || customSealScale <= 0) {
|
||||
customSealScale = 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Construtor para compatibilidade com assinaturas anteriores a customSealScale.
|
||||
*/
|
||||
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,
|
||||
String customExtraInfo) {
|
||||
this(certificateId, pinOrPassword, visibleSeal, sealTheme, position, pageChoice, reason, location,
|
||||
customX, customY, targetPageNumber, cpfDisplayMode, includeDateTime, customExtraInfo, 1.0);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,7 +77,7 @@ public record PdfSignatureOptions(
|
||||
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);
|
||||
customX, customY, targetPageNumber, cpfDisplayMode, includeDateTime, null, 1.0);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,7 +86,7 @@ public record PdfSignatureOptions(
|
||||
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);
|
||||
this(certificateId, pinOrPassword, visibleSeal, sealTheme, position, pageChoice, reason, location, null, null, null, null, null, null, 1.0);
|
||||
}
|
||||
|
||||
public PdfSignatureOptions(String certificateId, char[] pinOrPassword, boolean visibleSeal,
|
||||
@@ -143,6 +159,7 @@ public record PdfSignatureOptions(
|
||||
}
|
||||
|
||||
private String customExtraInfo;
|
||||
private Double customSealScale = 1.0;
|
||||
|
||||
public Builder includeDateTime(boolean include) {
|
||||
this.includeDateTime = include;
|
||||
@@ -154,6 +171,11 @@ public record PdfSignatureOptions(
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder customSealScale(Double scale) {
|
||||
this.customSealScale = scale;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder customPosition(Float x, Float y, Integer pageNumber) {
|
||||
this.customX = x;
|
||||
this.customY = y;
|
||||
@@ -179,7 +201,8 @@ public record PdfSignatureOptions(
|
||||
targetPageNumber,
|
||||
cpfDisplayMode,
|
||||
includeDateTime,
|
||||
customExtraInfo
|
||||
customExtraInfo,
|
||||
customSealScale
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package br.com.cybercore.signer.core.pdf;
|
||||
|
||||
import br.com.cybercore.signer.core.crypto.X509CertificateUtils;
|
||||
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;
|
||||
@@ -9,13 +10,25 @@ 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.io.RandomAccessRead;
|
||||
import org.apache.pdfbox.io.RandomAccessReadBuffer;
|
||||
import org.apache.pdfbox.io.RandomAccessReadBufferedFile;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.PDResources;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.common.PDStream;
|
||||
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceDictionary;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceStream;
|
||||
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.PDSignature;
|
||||
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.SignatureInterface;
|
||||
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.SignatureOptions;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField;
|
||||
import org.apache.pdfbox.rendering.PDFRenderer;
|
||||
import org.bouncycastle.cert.jcajce.JcaCertStore;
|
||||
import org.bouncycastle.cms.CMSProcessableByteArray;
|
||||
@@ -66,22 +79,69 @@ public class PdfSigningService {
|
||||
this.certificateManager = Objects.requireNonNull(certificateManager, "CertificateManager não pode ser nulo.");
|
||||
}
|
||||
|
||||
public record PageDimensions(float width, float height) {}
|
||||
|
||||
/**
|
||||
* 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)) {
|
||||
try (RandomAccessReadBuffer rar = new RandomAccessReadBuffer(pdfBytes);
|
||||
PDDocument doc = Loader.loadPDF(rar)) {
|
||||
return doc.getNumberOfPages();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retorna a quantidade total de páginas contidas no arquivo PDF.
|
||||
*/
|
||||
public static int getPageCount(File pdfFile) throws IOException {
|
||||
Objects.requireNonNull(pdfFile, "Arquivo PDF não pode ser nulo.");
|
||||
try (RandomAccessReadBufferedFile rar = new RandomAccessReadBufferedFile(pdfFile);
|
||||
PDDocument doc = Loader.loadPDF(rar)) {
|
||||
return doc.getNumberOfPages();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retorna as dimensões em pontos (pt) da página informada do documento PDF.
|
||||
*/
|
||||
public static PageDimensions getPageDimensions(byte[] pdfBytes, int pageIndex0Based) throws IOException {
|
||||
Objects.requireNonNull(pdfBytes, "Bytes do PDF não podem ser nulos.");
|
||||
try (RandomAccessReadBuffer rar = new RandomAccessReadBuffer(pdfBytes);
|
||||
PDDocument doc = Loader.loadPDF(rar)) {
|
||||
if (pageIndex0Based < 0 || pageIndex0Based >= doc.getNumberOfPages()) {
|
||||
throw new IllegalArgumentException("Índice de página inválido: " + pageIndex0Based + ". Total: " + doc.getNumberOfPages());
|
||||
}
|
||||
PDPage page = doc.getPage(pageIndex0Based);
|
||||
PDRectangle mediaBox = page.getMediaBox();
|
||||
return new PageDimensions(mediaBox.getWidth(), mediaBox.getHeight());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retorna as dimensões em pontos (pt) da página informada do arquivo PDF.
|
||||
*/
|
||||
public static PageDimensions getPageDimensions(File pdfFile, int pageIndex0Based) throws IOException {
|
||||
Objects.requireNonNull(pdfFile, "Arquivo PDF não pode ser nulo.");
|
||||
try (RandomAccessReadBufferedFile rar = new RandomAccessReadBufferedFile(pdfFile);
|
||||
PDDocument doc = Loader.loadPDF(rar)) {
|
||||
if (pageIndex0Based < 0 || pageIndex0Based >= doc.getNumberOfPages()) {
|
||||
throw new IllegalArgumentException("Índice de página inválido: " + pageIndex0Based + ". Total: " + doc.getNumberOfPages());
|
||||
}
|
||||
PDPage page = doc.getPage(pageIndex0Based);
|
||||
PDRectangle mediaBox = page.getMediaBox();
|
||||
return new PageDimensions(mediaBox.getWidth(), mediaBox.getHeight());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)) {
|
||||
try (RandomAccessReadBuffer rar = new RandomAccessReadBuffer(pdfBytes);
|
||||
PDDocument doc = Loader.loadPDF(rar)) {
|
||||
if (pageIndex0Based < 0 || pageIndex0Based >= doc.getNumberOfPages()) {
|
||||
throw new IllegalArgumentException("Índice de página inválido: " + pageIndex0Based + ". Total: " + doc.getNumberOfPages());
|
||||
}
|
||||
@@ -101,23 +161,55 @@ public class PdfSigningService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Assina digitalmente um arquivo PDF do sistema de arquivos e grava o resultado no arquivo de destino.
|
||||
* Assina digitalmente um arquivo PDF do sistema de arquivos e grava o resultado no arquivo de destino,
|
||||
* utilizando streaming direto via RandomAccessReadBufferedFile para minimizar o consumo de memória heap.
|
||||
*/
|
||||
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 (!sourcePdf.exists() || !sourcePdf.isFile()) {
|
||||
throw new IllegalArgumentException("Arquivo PDF de origem não existe: " + sourcePdf.getAbsolutePath());
|
||||
}
|
||||
|
||||
if (targetPdf.getParentFile() != null) {
|
||||
targetPdf.getParentFile().mkdirs();
|
||||
boolean sameFile = sourcePdf.getCanonicalPath().equals(targetPdf.getCanonicalPath());
|
||||
File parentDir = targetPdf.getParentFile();
|
||||
if (parentDir != null && !parentDir.exists()) {
|
||||
parentDir.mkdirs();
|
||||
}
|
||||
try (FileOutputStream fos = new FileOutputStream(targetPdf)) {
|
||||
fos.write(signedBytes);
|
||||
|
||||
File effectiveTarget = sameFile
|
||||
? Files.createTempFile("cybercore_signed_", ".pdf").toFile()
|
||||
: targetPdf;
|
||||
|
||||
try {
|
||||
// Calcula o hash SHA-256 via streaming em blocos de 64KB sem carregar o arquivo na RAM
|
||||
MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
|
||||
try (InputStream is = Files.newInputStream(sourcePdf.toPath())) {
|
||||
byte[] buffer = new byte[65536];
|
||||
int read;
|
||||
while ((read = is.read(buffer)) != -1) {
|
||||
sha256.update(buffer, 0, read);
|
||||
}
|
||||
}
|
||||
byte[] pdfDigest = sha256.digest();
|
||||
|
||||
try (RandomAccessReadBufferedFile rar = new RandomAccessReadBufferedFile(sourcePdf);
|
||||
FileOutputStream fos = new FileOutputStream(effectiveTarget)) {
|
||||
signPdfInternal(rar, fos, options, pdfDigest);
|
||||
}
|
||||
|
||||
if (sameFile) {
|
||||
Files.move(effectiveTarget.toPath(), targetPdf.toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
} finally {
|
||||
if (sameFile && effectiveTarget.exists()) {
|
||||
Files.deleteIfExists(effectiveTarget.toPath());
|
||||
}
|
||||
}
|
||||
logger.info("PDF assinado com sucesso e salvo em: {}", targetPdf.getAbsolutePath());
|
||||
|
||||
logger.info("PDF assinado com sucesso e salvo em: {}", targetPdf.getAbsolutePath().replaceAll("[\r\n]", ""));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -128,6 +220,39 @@ public class PdfSigningService {
|
||||
Objects.requireNonNull(pdfBytes, "Bytes do PDF não podem ser nulos.");
|
||||
Objects.requireNonNull(options, "Opções de assinatura não podem ser nulas.");
|
||||
|
||||
MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
|
||||
byte[] pdfDigest = sha256.digest(pdfBytes);
|
||||
|
||||
// Se o PDF for muito grande (> 2MB), usamos um arquivo temporário com RandomAccessReadBufferedFile
|
||||
// para aliviar significativamente a pressão no Garbage Collector e economizar memória heap.
|
||||
if (pdfBytes.length > 2 * 1024 * 1024) {
|
||||
File tempSource = Files.createTempFile("cybercore_in_", ".pdf").toFile();
|
||||
File tempTarget = Files.createTempFile("cybercore_out_", ".pdf").toFile();
|
||||
try {
|
||||
Files.write(tempSource.toPath(), pdfBytes);
|
||||
try (RandomAccessReadBufferedFile rar = new RandomAccessReadBufferedFile(tempSource);
|
||||
FileOutputStream fos = new FileOutputStream(tempTarget)) {
|
||||
signPdfInternal(rar, fos, options, pdfDigest);
|
||||
}
|
||||
return Files.readAllBytes(tempTarget.toPath());
|
||||
} finally {
|
||||
Files.deleteIfExists(tempSource.toPath());
|
||||
Files.deleteIfExists(tempTarget.toPath());
|
||||
}
|
||||
}
|
||||
|
||||
// Para PDFs de tamanho padrão (< 2MB), processa em memória com buffer pré-alocado
|
||||
ByteArrayOutputStream finalOut = new ByteArrayOutputStream(pdfBytes.length + 65536);
|
||||
try (RandomAccessReadBuffer rar = new RandomAccessReadBuffer(pdfBytes)) {
|
||||
signPdfInternal(rar, finalOut, options, pdfDigest);
|
||||
}
|
||||
|
||||
logger.info("Assinatura PAdES concluída com sucesso. Tamanho final: {} bytes", finalOut.size());
|
||||
return finalOut.toByteArray();
|
||||
}
|
||||
|
||||
private void signPdfInternal(RandomAccessRead rar, OutputStream targetOut,
|
||||
PdfSignatureOptions options, byte[] pdfDigest) throws Exception {
|
||||
// 1. Localiza o certificado e o material criptográfico (chave e cadeia)
|
||||
Optional<ManagedCertificate> certOpt = certificateManager.findById(options.certificateId());
|
||||
if (certOpt.isEmpty()) {
|
||||
@@ -155,8 +280,10 @@ public class PdfSigningService {
|
||||
String issuerCn = extractIssuerCn(cert.issuerDn());
|
||||
String signingTimeStr = DATE_TIME_FORMATTER.format(Instant.now());
|
||||
|
||||
MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
|
||||
byte[] pdfDigest = sha256.digest(pdfBytes);
|
||||
// Extrai o nome amigável do titular (removendo qualquer sufixo como :CPF ou :CNPJ para o nome do assinante)
|
||||
String rawName = cert.commonName() != null ? cert.commonName() : cert.alias();
|
||||
String signerName = X509CertificateUtils.extractHolderName(rawName);
|
||||
|
||||
String hashShort = bytesToHex(pdfDigest).substring(0, 16).toUpperCase();
|
||||
|
||||
PdfSealTheme theme = options.sealTheme();
|
||||
@@ -166,7 +293,7 @@ public class PdfSigningService {
|
||||
|
||||
// 3. Monta dados do selo visual
|
||||
PdfSealData sealData = new PdfSealData(
|
||||
cert.commonName() != null ? cert.commonName() : cert.alias(),
|
||||
signerName,
|
||||
signerDoc,
|
||||
signingTimeStr,
|
||||
issuerCn,
|
||||
@@ -178,119 +305,144 @@ public class PdfSigningService {
|
||||
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)) {
|
||||
// 4. Carrega o PDF original e aplica a assinatura PAdES via atualização incremental (Incremental Update),
|
||||
// preservando intactas quaisquer assinaturas digitais pré-existentes. Em conformidade com a ISO 32000-1 / ETSI PAdES,
|
||||
// o selo visual é associado diretamente ao widget da assinatura (/Widget /AP /N) sem alterar o stream /Contents
|
||||
// das páginas existentes, garantindo que o validador do ITI aprove a verificação incremental sem indeterminação.
|
||||
try (PDDocument doc = Loader.loadPDF(rar)) {
|
||||
PDSignature signature = new PDSignature();
|
||||
signature.setFilter(PDSignature.FILTER_ADOBE_PPKLITE);
|
||||
signature.setSubFilter(PDSignature.SUBFILTER_ADBE_PKCS7_DETACHED);
|
||||
signature.setName(cert.commonName());
|
||||
signature.setName(signerName);
|
||||
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();
|
||||
if (options.visibleSeal()) {
|
||||
byte[] sealPng = PdfSealRenderer.renderToPngBytes(theme, sealData);
|
||||
int targetPage = resolveTargetPage(doc, options);
|
||||
PDRectangle rect = calculateSealRectangle(doc, targetPage, theme, options);
|
||||
|
||||
try (InputStream visualTemplate = createVisualSignatureTemplate(doc, targetPage, rect, sealPng);
|
||||
SignatureOptions signatureOptions = new SignatureOptions()) {
|
||||
signatureOptions.setVisualSignature(visualTemplate);
|
||||
signatureOptions.setPage(targetPage);
|
||||
doc.addSignature(signature, signer, signatureOptions);
|
||||
doc.saveIncremental(targetOut);
|
||||
}
|
||||
} else {
|
||||
doc.addSignature(signature, signer);
|
||||
doc.saveIncremental(targetOut);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
private int resolveTargetPage(PDDocument doc, PdfSignatureOptions options) {
|
||||
int totalPages = doc.getNumberOfPages();
|
||||
if (totalPages <= 0) {
|
||||
return 0;
|
||||
}
|
||||
if (options.targetPageNumber() != null && options.targetPageNumber() >= 1 && options.targetPageNumber() <= totalPages) {
|
||||
return options.targetPageNumber() - 1;
|
||||
}
|
||||
if (options.pageChoice() == PdfSealPageChoice.FIRST_PAGE) {
|
||||
return 0;
|
||||
}
|
||||
return totalPages - 1;
|
||||
}
|
||||
|
||||
private PDRectangle calculateSealRectangle(PDDocument doc, int pageIndex, PdfSealTheme theme, PdfSignatureOptions options) {
|
||||
PDPage page = doc.getPage(pageIndex);
|
||||
PDRectangle mediaBox = page.getMediaBox();
|
||||
float pageW = mediaBox.getWidth();
|
||||
float pageH = mediaBox.getHeight();
|
||||
|
||||
PDImageXObject pdImage = PDImageXObject.createFromByteArray(doc, sealPng, "cybercore_seal");
|
||||
float scale = (options.customSealScale() != null && options.customSealScale() > 0)
|
||||
? options.customSealScale().floatValue()
|
||||
: 1.0f;
|
||||
float sealWidth = (theme.width() * 0.72f) * scale;
|
||||
float sealHeight = (theme.height() * 0.72f) * scale;
|
||||
float margin = 20f;
|
||||
|
||||
float sealWidth = theme.width() * 0.72f;
|
||||
float sealHeight = theme.height() * 0.72f;
|
||||
float margin = 20f;
|
||||
float x;
|
||||
float y;
|
||||
|
||||
// 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;
|
||||
if (options.customX() != null && options.customY() != null) {
|
||||
x = Math.max(0, Math.min(options.customX(), pageW - sealWidth));
|
||||
y = Math.max(0, Math.min(options.customY(), pageH - sealHeight));
|
||||
} else {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PDPage page = doc.getPage(targetPageIdx);
|
||||
PDRectangle mediaBox = page.getMediaBox();
|
||||
float pageW = mediaBox.getWidth();
|
||||
float pageH = mediaBox.getHeight();
|
||||
return new PDRectangle(x, y, sealWidth, sealHeight);
|
||||
}
|
||||
|
||||
float x = Math.max(0, Math.min(options.customX(), pageW - sealWidth));
|
||||
float y = Math.max(0, Math.min(options.customY(), pageH - sealHeight));
|
||||
/**
|
||||
* Cria um documento template PAdES para o SignatureOptions do PDFBox, incorporando o selo visual
|
||||
* como a Aparência Normal (/AP /N) da anotação da assinatura (PDAnnotationWidget).
|
||||
* O stream /Contents da página do documento original permanece completamente inalterado.
|
||||
*/
|
||||
private InputStream createVisualSignatureTemplate(PDDocument srcDoc, int pageNum,
|
||||
PDRectangle rect, byte[] sealPngBytes) throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage(srcDoc.getPage(pageNum).getMediaBox());
|
||||
doc.addPage(page);
|
||||
|
||||
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();
|
||||
}
|
||||
PDAcroForm acroForm = new PDAcroForm(doc);
|
||||
doc.getDocumentCatalog().setAcroForm(acroForm);
|
||||
|
||||
// Caso contrário, usa posicionamento por enum (BOTTOM_RIGHT, etc.)
|
||||
int startPage = 0;
|
||||
int endPage = totalPages - 1;
|
||||
PDSignatureField signatureField = new PDSignatureField(acroForm);
|
||||
PDAnnotationWidget widget = signatureField.getWidgets().get(0);
|
||||
acroForm.getFields().add(signatureField);
|
||||
acroForm.setSignaturesExist(true);
|
||||
acroForm.setAppendOnly(true);
|
||||
acroForm.getCOSObject().setDirect(true);
|
||||
|
||||
if (options.pageChoice() == PdfSealPageChoice.FIRST_PAGE) {
|
||||
endPage = 0;
|
||||
} else if (options.pageChoice() == PdfSealPageChoice.LAST_PAGE) {
|
||||
startPage = totalPages - 1;
|
||||
}
|
||||
widget.setRectangle(rect);
|
||||
|
||||
for (int i = startPage; i <= endPage; i++) {
|
||||
PDPage page = doc.getPage(i);
|
||||
PDRectangle mediaBox = page.getMediaBox();
|
||||
float pageW = mediaBox.getWidth();
|
||||
float pageH = mediaBox.getHeight();
|
||||
PDStream stream = new PDStream(doc);
|
||||
PDFormXObject form = new PDFormXObject(stream);
|
||||
PDResources res = new PDResources();
|
||||
form.setResources(res);
|
||||
form.setFormType(1);
|
||||
|
||||
float x;
|
||||
float y;
|
||||
PDRectangle bbox = new PDRectangle(rect.getWidth(), rect.getHeight());
|
||||
form.setBBox(bbox);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
PDAppearanceDictionary appearance = new PDAppearanceDictionary();
|
||||
appearance.getCOSObject().setDirect(true);
|
||||
PDAppearanceStream appearanceStream = new PDAppearanceStream(form.getCOSObject());
|
||||
appearance.setNormalAppearance(appearanceStream);
|
||||
widget.setAppearance(appearance);
|
||||
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page, PDPageContentStream.AppendMode.APPEND, true, true)) {
|
||||
cs.drawImage(pdImage, x, y, sealWidth, sealHeight);
|
||||
}
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, appearanceStream)) {
|
||||
PDImageXObject img = PDImageXObject.createFromByteArray(doc, sealPngBytes, "cybercore_seal");
|
||||
cs.drawImage(img, 0, 0, rect.getWidth(), rect.getHeight());
|
||||
}
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
return new ByteArrayInputStream(baos.toByteArray());
|
||||
}
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
private static String bytesToHex(byte[] bytes) {
|
||||
|
||||
@@ -112,8 +112,10 @@ public final class PdfSealRenderer {
|
||||
String headerText = resolveHeaderText(theme);
|
||||
String footerText = resolveFooterText(theme, null);
|
||||
|
||||
Color footerColor = new Color(100, 116, 139); // Cinza corporativo neutro e sóbrio (#64748b)
|
||||
|
||||
if (hasIdentity && hasDate) {
|
||||
// Layout com 5 linhas: Cabeçalho, Nome, Identificação (CPF/Matrícula), Data, Rodapé
|
||||
// Layout com 5 linhas: Cabeçalho, Nome, Identificação (CPF/CNPJ/Matrícula), Data, Rodapé
|
||||
g2d.setColor(primaryColor);
|
||||
g2d.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 9));
|
||||
g2d.drawString(headerText, textX, 16);
|
||||
@@ -127,9 +129,8 @@ public final class PdfSealRenderer {
|
||||
|
||||
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);
|
||||
g2d.setColor(footerColor);
|
||||
drawFittedText(g2d, footerText, textX, 73, textWidthAvailable, 7.8f);
|
||||
|
||||
} else if (hasIdentity || hasDate) {
|
||||
// Layout com 4 linhas: Cabeçalho, Nome, (Identificação ou Data), Rodapé
|
||||
@@ -145,9 +146,8 @@ public final class PdfSealRenderer {
|
||||
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);
|
||||
g2d.setColor(footerColor);
|
||||
drawFittedText(g2d, footerText, textX, 71, textWidthAvailable, 7.8f);
|
||||
|
||||
} else {
|
||||
// Layout com 3 linhas: Cabeçalho, Nome em Destaque Máximo, Rodapé
|
||||
@@ -158,9 +158,8 @@ public final class PdfSealRenderer {
|
||||
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);
|
||||
g2d.setColor(footerColor);
|
||||
drawFittedText(g2d, footerText, textX, 68, textWidthAvailable, 8.0f);
|
||||
}
|
||||
|
||||
} finally {
|
||||
@@ -250,19 +249,28 @@ public final class PdfSealRenderer {
|
||||
* 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() : "";
|
||||
String rawDoc = data.identifier() != null ? data.identifier().trim() : "";
|
||||
if (rawDoc.isBlank()) return "";
|
||||
|
||||
if (mode == CpfDisplayMode.NONE) {
|
||||
// Se for CPF, oculta. Se for CNPJ, pode manter ou formatar.
|
||||
if (rawDoc.toUpperCase().contains("CPF")) {
|
||||
return "";
|
||||
String digits = rawDoc.replaceAll("\\D", "");
|
||||
|
||||
// Pessoa Jurídica (14 dígitos): Rótulo CNPJ, SEMPRE completo, sem mascaramento
|
||||
if (digits.length() == 14 || rawDoc.toUpperCase().contains("CNPJ")) {
|
||||
if (digits.length() == 14) {
|
||||
return "CNPJ: " + String.format("%s.%s.%s/%s-%s",
|
||||
digits.substring(0, 2), digits.substring(2, 5), digits.substring(5, 8),
|
||||
digits.substring(8, 12), digits.substring(12, 14));
|
||||
}
|
||||
return rawDoc;
|
||||
return rawDoc.toUpperCase().startsWith("CNPJ") ? rawDoc : "CNPJ: " + rawDoc;
|
||||
}
|
||||
|
||||
if (rawDoc.toUpperCase().contains("CPF")) {
|
||||
String digits = rawDoc.replaceAll("\\D", "");
|
||||
// Pessoa Física (11 dígitos): Rótulo CPF, respeitando CpfDisplayMode
|
||||
CpfDisplayMode mode = data.cpfDisplayMode() != null ? data.cpfDisplayMode() : CpfDisplayMode.FULL;
|
||||
if (mode == CpfDisplayMode.NONE) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (digits.length() == 11) {
|
||||
String formatted = mode.formatCpf(digits);
|
||||
return formatted.isBlank() ? "" : "CPF: " + formatted;
|
||||
}
|
||||
@@ -378,10 +386,10 @@ public final class PdfSealRenderer {
|
||||
return theme.customHeader().toUpperCase();
|
||||
}
|
||||
return switch (theme.id().toLowerCase()) {
|
||||
case "cybercore-modern" -> "CYBERCORE SIGNER • AUTENTICAÇÃO DIGITAL";
|
||||
case "cybercore-modern" -> "DOCUMENTO ASSINADO DIGITALMENTE";
|
||||
case "minimal-clean" -> "DOCUMENTO ASSINADO DIGITALMENTE";
|
||||
case "gov-br" -> "GOV.BR • CONFORMIDADE MP Nº 2.200-2/2001";
|
||||
default -> "ASSINADO DIGITALMENTE • ICP-BRASIL";
|
||||
case "gov-br" -> "DOCUMENTO ASSINADO DIGITALMENTE";
|
||||
default -> "DOCUMENTO ASSINADO DIGITALMENTE";
|
||||
};
|
||||
}
|
||||
|
||||
@@ -393,10 +401,10 @@ public final class PdfSealRenderer {
|
||||
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";
|
||||
case "cybercore-modern" -> "Verifique a autenticidade em https://validar.iti.gov.br";
|
||||
case "minimal-clean" -> "Verifique a autenticidade em https://validar.iti.gov.br";
|
||||
case "gov-br" -> "Verifique a autenticidade em https://validar.iti.gov.br";
|
||||
default -> "Verifique a autenticidade em https://validar.iti.gov.br";
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -35,10 +35,10 @@ public final class PdfSealThemeRegistry {
|
||||
}
|
||||
|
||||
private static void registerBuiltInThemes() {
|
||||
// 1. Tema ICP-Brasil Institucional
|
||||
// Único Selo Padrão Embutido do Sistema: ICP-Brasil Institucional Oficial
|
||||
registerTheme(new PdfSealTheme(
|
||||
"icp-brasil",
|
||||
"ICP-Brasil Institucional",
|
||||
DEFAULT_THEME_ID,
|
||||
"ICP-Brasil Institucional (Padrão)",
|
||||
"Estilo oficial corporativo com escudo de segurança vetorial e dados completos do titular",
|
||||
320,
|
||||
82,
|
||||
@@ -51,7 +51,7 @@ public final class PdfSealThemeRegistry {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 82" width="320" height="82">
|
||||
<rect x="1" y="1" width="318" height="80" rx="6" fill="${THEME_BG}" stroke="${THEME_BORDER}" stroke-width="1.5"/>
|
||||
<rect x="1" y="1" width="6" height="80" rx="3" fill="${THEME_PRIMARY}"/>
|
||||
<!-- Escudo Criptográfico Vetorial -->
|
||||
<!-- Escudo Criptográfico Vetorial ICP-Brasil -->
|
||||
<g transform="translate(14, 18)">
|
||||
<path d="M18 2 L32 7 V18 C32 28 25 36 18 39 C11 36 4 28 4 18 V7 Z" fill="${THEME_PRIMARY}" opacity="0.12"/>
|
||||
<path d="M18 2 L32 7 V18 C32 28 25 36 18 39 C11 36 4 28 4 18 V7 Z" fill="none" stroke="${THEME_PRIMARY}" stroke-width="2"/>
|
||||
@@ -65,104 +65,7 @@ public final class PdfSealThemeRegistry {
|
||||
<text x="56" y="32" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="12" font-weight="bold" fill="${THEME_TEXT}">${SIGNER_NAME}</text>
|
||||
<text x="56" y="46" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="8.5" fill="#475569">Doc: ${SIGNER_DOC} • AC: ${CERT_ISSUER}</text>
|
||||
<text x="56" y="59" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="8.5" fill="#475569">Data/Hora: ${SIGNING_DATE}</text>
|
||||
<text x="56" y="72" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="7.5" fill="${THEME_SECONDARY}">Verificação criptográfica válida e com fé pública</text>
|
||||
</svg>
|
||||
"""
|
||||
));
|
||||
|
||||
// 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",
|
||||
"""
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 82" width="320" height="82">
|
||||
<rect x="1" y="1" width="318" height="80" rx="8" fill="${THEME_BG}" stroke="${THEME_BORDER}" stroke-width="1.5"/>
|
||||
<rect x="1" y="1" width="5" height="80" rx="2.5" fill="${THEME_SECONDARY}"/>
|
||||
<!-- Cristal CyberCore Vetorial -->
|
||||
<g transform="translate(14, 20)">
|
||||
<polygon points="18,2 32,10 32,28 18,36 4,28 4,10" fill="${THEME_PRIMARY}" opacity="0.15"/>
|
||||
<polygon points="18,2 32,10 32,28 18,36 4,28 4,10" fill="none" stroke="${THEME_PRIMARY}" stroke-width="1.8"/>
|
||||
<line x1="18" y1="2" x2="18" y2="36" stroke="${THEME_SECONDARY}" stroke-width="1.2"/>
|
||||
<line x1="4" y1="19" x2="32" y2="19" stroke="${THEME_PRIMARY}" stroke-width="1.2"/>
|
||||
</g>
|
||||
<!-- Dados da Assinatura -->
|
||||
<text x="56" y="17" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="8.5" font-weight="bold" fill="${THEME_PRIMARY}">CYBERCORE SIGNER • ASSINATURA ELETRÔNICA</text>
|
||||
<text x="56" y="32" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="12" font-weight="bold" fill="${THEME_TEXT}">${SIGNER_NAME}</text>
|
||||
<text x="56" y="46" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="8.5" fill="#475569">${SIGNER_DOC} • ${CERT_ISSUER}</text>
|
||||
<text x="56" y="59" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="8.5" fill="#475569">Autenticado em: ${SIGNING_DATE}</text>
|
||||
<text x="56" y="72" font-family="monospace" font-size="7.5" fill="${THEME_SECONDARY}">Hash: ${HASH_SUMMARY}</text>
|
||||
</svg>
|
||||
"""
|
||||
));
|
||||
|
||||
// 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",
|
||||
"""
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 76" width="300" height="76">
|
||||
<rect x="1" y="1" width="298" height="74" rx="4" fill="${THEME_BG}" stroke="${THEME_BORDER}" stroke-width="1"/>
|
||||
<!-- Ícone de Carimbo Notarial -->
|
||||
<g transform="translate(12, 18)">
|
||||
<circle cx="18" cy="18" r="16" fill="none" stroke="${THEME_PRIMARY}" stroke-width="1.5"/>
|
||||
<circle cx="18" cy="18" r="13" fill="none" stroke="${THEME_PRIMARY}" stroke-width="0.8" stroke-dasharray="2,2"/>
|
||||
<path d="M12 18 L16 22 L24 14" fill="none" stroke="${THEME_PRIMARY}" stroke-width="2"/>
|
||||
</g>
|
||||
<!-- Dados da Assinatura -->
|
||||
<text x="52" y="17" font-family="Georgia, serif" font-size="9" font-weight="bold" fill="${THEME_PRIMARY}">Assinado Digitalmente</text>
|
||||
<text x="52" y="32" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="11.5" font-weight="bold" fill="${THEME_TEXT}">${SIGNER_NAME}</text>
|
||||
<text x="52" y="46" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="8" fill="#475569">${SIGNER_DOC} • ${CERT_ISSUER}</text>
|
||||
<text x="52" y="58" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="8" fill="#475569">${SIGNING_DATE}</text>
|
||||
<text x="52" y="69" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="7.5" fill="#64748b">Conforme MP 2.200-2/2001 e ICP-Brasil</text>
|
||||
</svg>
|
||||
"""
|
||||
));
|
||||
|
||||
// 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",
|
||||
"""
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 82" width="320" height="82">
|
||||
<rect x="1" y="1" width="318" height="80" rx="4" fill="${THEME_BG}" stroke="${THEME_BORDER}" stroke-width="1.5"/>
|
||||
<rect x="1" y="1" width="6" height="80" rx="2" fill="${THEME_PRIMARY}"/>
|
||||
<rect x="7" y="1" width="3" height="80" fill="${THEME_SECONDARY}"/>
|
||||
<!-- Brasão Estilizado -->
|
||||
<g transform="translate(16, 20)">
|
||||
<circle cx="17" cy="18" r="15" fill="${THEME_PRIMARY}" opacity="0.1"/>
|
||||
<circle cx="17" cy="18" r="15" fill="none" stroke="${THEME_PRIMARY}" stroke-width="1.5"/>
|
||||
<path d="M17 6 L20 14 L28 14 L22 19 L24 27 L17 22 L10 27 L12 19 L6 14 L14 14 Z" fill="${THEME_SECONDARY}"/>
|
||||
</g>
|
||||
<!-- Dados da Assinatura -->
|
||||
<text x="56" y="17" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="8.5" font-weight="bold" fill="${THEME_PRIMARY}">GOV.BR • DOCUMENTO ASSINADO DIGITALMENTE</text>
|
||||
<text x="56" y="32" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="12" font-weight="bold" fill="${THEME_TEXT}">${SIGNER_NAME}</text>
|
||||
<text x="56" y="46" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="8.5" fill="#065f46">Doc: ${SIGNER_DOC} • AC: ${CERT_ISSUER}</text>
|
||||
<text x="56" y="59" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="8.5" fill="#475569">Data/Hora: ${SIGNING_DATE}</text>
|
||||
<text x="56" y="72" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="7.5" fill="#047857">Validade jurídica assegurada pela MP nº 2.200-2/2001</text>
|
||||
<text x="56" y="72" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="7.5" fill="#64748b">Verifique a autenticidade em https://validar.iti.gov.br</text>
|
||||
</svg>
|
||||
"""
|
||||
));
|
||||
@@ -205,10 +108,9 @@ public final class PdfSealThemeRegistry {
|
||||
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.");
|
||||
// Protege o tema padrão
|
||||
if (idLower.equals(DEFAULT_THEME_ID)) {
|
||||
throw new IllegalArgumentException("Não é permitido excluir o selo padrão pré-instalado do sistema.");
|
||||
}
|
||||
|
||||
boolean deleted = STORAGE.deleteSeal(idLower);
|
||||
@@ -218,7 +120,11 @@ public final class PdfSealThemeRegistry {
|
||||
|
||||
public static Optional<PdfSealTheme> findById(String id) {
|
||||
if (id == null) return Optional.empty();
|
||||
return Optional.ofNullable(THEMES.get(id.toLowerCase()));
|
||||
PdfSealTheme theme = THEMES.get(id.toLowerCase());
|
||||
if (theme == null && ("cybercore-modern".equalsIgnoreCase(id) || "minimal-clean".equalsIgnoreCase(id) || "gov-br".equalsIgnoreCase(id))) {
|
||||
return Optional.of(getDefaultTheme());
|
||||
}
|
||||
return Optional.ofNullable(theme);
|
||||
}
|
||||
|
||||
public static Optional<PdfSealTheme> getTheme(String id) {
|
||||
|
||||
@@ -56,7 +56,73 @@ public record CcspRequest(
|
||||
String purpose,
|
||||
|
||||
@JsonProperty("challenge")
|
||||
String challenge
|
||||
String challenge,
|
||||
|
||||
@JsonProperty("pdf")
|
||||
String pdf,
|
||||
|
||||
@JsonProperty("page")
|
||||
Integer page,
|
||||
|
||||
@JsonProperty("page_choice")
|
||||
String pageChoice,
|
||||
|
||||
@JsonProperty("position")
|
||||
String position,
|
||||
|
||||
@JsonProperty("x")
|
||||
Float x,
|
||||
|
||||
@JsonProperty("y")
|
||||
Float y,
|
||||
|
||||
@JsonProperty("scale")
|
||||
Double scale,
|
||||
|
||||
@JsonProperty("visible_seal")
|
||||
Boolean visibleSeal,
|
||||
|
||||
@JsonProperty("seal_theme_id")
|
||||
String sealThemeId,
|
||||
|
||||
@JsonProperty("seal_custom")
|
||||
java.util.Map<String, Object> sealCustom,
|
||||
|
||||
@JsonProperty("reason")
|
||||
String reason,
|
||||
|
||||
@JsonProperty("location")
|
||||
String location,
|
||||
|
||||
@JsonProperty("cpf_display_mode")
|
||||
String cpfDisplayMode,
|
||||
|
||||
@JsonProperty("include_date_time")
|
||||
Boolean includeDateTime,
|
||||
|
||||
@JsonProperty("custom_extra_info")
|
||||
String customExtraInfo,
|
||||
|
||||
@JsonProperty("xml")
|
||||
String xml,
|
||||
|
||||
@JsonProperty("xml_base64")
|
||||
String xmlBase64,
|
||||
|
||||
@JsonProperty("profile")
|
||||
String profile,
|
||||
|
||||
@JsonProperty("target_element_tag")
|
||||
String targetElementTag,
|
||||
|
||||
@JsonProperty("target_element_id")
|
||||
String targetElementId,
|
||||
|
||||
@JsonProperty("document_size")
|
||||
Long documentSize,
|
||||
|
||||
@JsonProperty("origin")
|
||||
String origin
|
||||
) implements Serializable {
|
||||
|
||||
public static final String PROTOCOL_VERSION = "1";
|
||||
@@ -68,7 +134,10 @@ public record CcspRequest(
|
||||
) {
|
||||
this(version, requestId, applicationId, operation, algorithm, digest,
|
||||
certificateHint, expiresAt, timestamp, nonce, signatureType,
|
||||
null, null, null, null);
|
||||
null, null, null, null,
|
||||
null, null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, null, null, null, null, null,
|
||||
null, null);
|
||||
}
|
||||
|
||||
public boolean isVersionSupported() {
|
||||
|
||||
@@ -49,7 +49,16 @@ public record CcspResponse(
|
||||
String documentName,
|
||||
|
||||
@JsonProperty("document_type")
|
||||
String documentType
|
||||
String documentType,
|
||||
|
||||
@JsonProperty("signed_pdf")
|
||||
String signedPdf,
|
||||
|
||||
@JsonProperty("signed_xml")
|
||||
String signedXml,
|
||||
|
||||
@JsonProperty("sha256")
|
||||
String sha256
|
||||
) implements Serializable {
|
||||
|
||||
public CcspResponse {
|
||||
@@ -88,7 +97,58 @@ public record CcspResponse(
|
||||
subjectName,
|
||||
subjectCpfCnpj,
|
||||
documentName,
|
||||
documentType
|
||||
documentType,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
public static CcspResponse pdfSuccess(String requestId, String signedPdfBase64, String sha256,
|
||||
String signatureBase64, String certBase64, List<String> chain,
|
||||
String algorithm, String subjectName, String subjectCpfCnpj,
|
||||
String documentName) {
|
||||
return new CcspResponse(
|
||||
CcspRequest.PROTOCOL_VERSION,
|
||||
requestId,
|
||||
STATUS_SIGNED,
|
||||
signatureBase64,
|
||||
certBase64,
|
||||
chain,
|
||||
algorithm,
|
||||
null,
|
||||
null,
|
||||
subjectName,
|
||||
subjectCpfCnpj,
|
||||
documentName,
|
||||
"PDF",
|
||||
signedPdfBase64,
|
||||
null,
|
||||
sha256
|
||||
);
|
||||
}
|
||||
|
||||
public static CcspResponse xmlSuccess(String requestId, String signedXml, String sha256,
|
||||
String signatureBase64, String certBase64, List<String> chain,
|
||||
String algorithm, String subjectName, String subjectCpfCnpj,
|
||||
String documentName) {
|
||||
return new CcspResponse(
|
||||
CcspRequest.PROTOCOL_VERSION,
|
||||
requestId,
|
||||
STATUS_SIGNED,
|
||||
signatureBase64,
|
||||
certBase64,
|
||||
chain,
|
||||
algorithm,
|
||||
null,
|
||||
null,
|
||||
subjectName,
|
||||
subjectCpfCnpj,
|
||||
documentName,
|
||||
"XML",
|
||||
null,
|
||||
signedXml,
|
||||
sha256
|
||||
);
|
||||
}
|
||||
|
||||
@@ -108,7 +168,10 @@ public record CcspResponse(
|
||||
subjectName,
|
||||
subjectCpfCnpj,
|
||||
null,
|
||||
"AUTH_CHALLENGE"
|
||||
"AUTH_CHALLENGE",
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
@@ -126,6 +189,9 @@ public record CcspResponse(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ 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.SigningKeyMaterial;
|
||||
import br.com.cybercore.signer.core.domain.SigningResult;
|
||||
import java.security.MessageDigest;
|
||||
import br.com.cybercore.signer.core.provider.CertificateManager;
|
||||
@@ -22,6 +23,20 @@ import com.sun.net.httpserver.HttpServer;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
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.PdfSealTheme;
|
||||
import br.com.cybercore.signer.core.pdf.seal.PdfSealThemeRegistry;
|
||||
import br.com.cybercore.signer.core.xml.XmlSignatureOptions;
|
||||
import br.com.cybercore.signer.core.xml.XmlSignatureProfile;
|
||||
import br.com.cybercore.signer.core.xml.XmlSigningService;
|
||||
import java.util.Arrays;
|
||||
import java.util.UUID;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
@@ -40,6 +55,7 @@ import java.util.Set;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ThreadFactory;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
||||
@@ -56,6 +72,8 @@ public class CcspServer {
|
||||
private final int port;
|
||||
private final CertificateManager certificateManager;
|
||||
private final SigningService signingService;
|
||||
private final PdfSigningService pdfSigningService;
|
||||
private final XmlSigningService xmlSigningService;
|
||||
private final ApplicationRegistry applicationRegistry;
|
||||
private final NonceManager nonceManager;
|
||||
private final OriginValidator originValidator;
|
||||
@@ -75,6 +93,8 @@ public class CcspServer {
|
||||
this.port = port;
|
||||
this.certificateManager = Objects.requireNonNull(certificateManager, "certificateManager cannot be null");
|
||||
this.signingService = Objects.requireNonNull(signingService, "signingService cannot be null");
|
||||
this.pdfSigningService = new PdfSigningService(this.certificateManager);
|
||||
this.xmlSigningService = new XmlSigningService(this.certificateManager);
|
||||
this.applicationRegistry = applicationRegistry != null ? applicationRegistry : ApplicationRegistry.getInstance();
|
||||
this.promptListener = promptListener;
|
||||
this.nonceManager = new NonceManager();
|
||||
@@ -101,11 +121,8 @@ public class CcspServer {
|
||||
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;
|
||||
});
|
||||
ThreadFactory virtualThreadFactory = Thread.ofVirtual().name("ccsp-virtual-worker-", 0).factory();
|
||||
executor = Executors.newThreadPerTaskExecutor(virtualThreadFactory);
|
||||
httpServer.setExecutor(executor);
|
||||
|
||||
// Registra endpoints do protocolo CCSP/1
|
||||
@@ -114,6 +131,9 @@ public class CcspServer {
|
||||
httpServer.createContext("/ccsp/v1/sign", new SignHandler());
|
||||
httpServer.createContext("/ccsp/v1/authenticate", new SignHandler());
|
||||
httpServer.createContext("/ccsp/v1/authorize", new AuthorizeHandler());
|
||||
httpServer.createContext("/ccsp/v1/sign-pdf", new SignPdfHandler());
|
||||
httpServer.createContext("/ccsp/v1/sign-xml", new SignXmlHandler());
|
||||
httpServer.createContext("/ccsp/v1/seals", new SealsHandler());
|
||||
|
||||
httpServer.start();
|
||||
running = true;
|
||||
@@ -196,16 +216,20 @@ public class CcspServer {
|
||||
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;
|
||||
}
|
||||
// Exige obrigatoriamente a identificação da aplicação para listagem de certificados
|
||||
if (appId == null || appId.isBlank()) {
|
||||
sendErrorResponse(exchange, 401, CcspErrorCode.APPLICATION_NOT_AUTHORIZED.name(),
|
||||
"application_id (via query param ou cabeçalho X-CyberCore-AppId) é obrigatório.");
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,10 +330,14 @@ public class CcspServer {
|
||||
try (InputStream is = exchange.getRequestBody()) {
|
||||
request = objectMapper.readValue(is, CcspRequest.class);
|
||||
} catch (Exception e) {
|
||||
logger.warn("[CCSP REQ INVÁLIDO] POST /ccsp/v1/sign de Origin: {} | Erro parsing: {}", origin, e.getMessage());
|
||||
sendCcspError(exchange, 400, null, CcspErrorCode.SIGNATURE_FAILED, "Payload de requisição inválido.");
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("[CCSP REQ] POST /ccsp/v1/sign | Origin: {} | ReqId: {} | App: {} | Op: {} | Algoritmo: {} | CertHint: {}",
|
||||
origin, request.requestId(), request.applicationId(), request.operation(), request.algorithm(), request.certificateHint());
|
||||
|
||||
// 1. Validação de versão
|
||||
if (!request.isVersionSupported()) {
|
||||
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.COMMUNICATION_ERROR,
|
||||
@@ -324,14 +352,17 @@ public class CcspServer {
|
||||
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;
|
||||
}
|
||||
// 3. Validação anti-replay (Nonce obrigatório)
|
||||
if (request.nonce() == null || request.nonce().isBlank()) {
|
||||
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.REPLAY_DETECTED,
|
||||
"O parâmetro 'nonce' é obrigatório para proteção contra replay attack.");
|
||||
return;
|
||||
}
|
||||
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
|
||||
@@ -458,6 +489,534 @@ public class CcspServer {
|
||||
}
|
||||
}
|
||||
|
||||
private class SignPdfHandler 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) {
|
||||
logger.warn("[CCSP REQ INVÁLIDO] POST /ccsp/v1/sign-pdf de Origin: {} | Erro parsing: {}", origin, e.getMessage());
|
||||
sendCcspError(exchange, 400, null, CcspErrorCode.SIGNATURE_FAILED, "Payload de requisição inválido.");
|
||||
return;
|
||||
}
|
||||
|
||||
int pdfLength = request.pdf() != null ? request.pdf().length() : 0;
|
||||
logger.info("[CCSP REQ] POST /ccsp/v1/sign-pdf | Origin: {} | ReqId: {} | App: {} | Doc: '{}' | Base64: {} chars | CertHint: {}",
|
||||
origin, request.requestId(), request.applicationId(), request.documentName(), pdfLength, request.certificateHint());
|
||||
|
||||
if (!request.isVersionSupported()) {
|
||||
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.COMMUNICATION_ERROR,
|
||||
"Versão do protocolo não suportada: " + request.version());
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.isExpired()) {
|
||||
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.REQUEST_EXPIRED,
|
||||
"A requisição de assinatura expirou.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validação anti-replay (Nonce obrigatório)
|
||||
if (request.nonce() == null || request.nonce().isBlank()) {
|
||||
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.REPLAY_DETECTED,
|
||||
"O parâmetro 'nonce' é obrigatório para proteção contra replay attack.");
|
||||
return;
|
||||
}
|
||||
boolean accepted = nonceManager.registerNonce(request.nonce());
|
||||
if (!accepted) {
|
||||
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.REPLAY_DETECTED,
|
||||
"Requisição duplicada detectada (replay prevenido).");
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
if (request.pdf() == null || request.pdf().isBlank()) {
|
||||
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.SIGNATURE_FAILED,
|
||||
"O campo 'pdf' contendo o documento em Base64 é obrigatório.");
|
||||
return;
|
||||
}
|
||||
|
||||
byte[] pdfBytes;
|
||||
try {
|
||||
pdfBytes = Base64.getDecoder().decode(request.pdf());
|
||||
} catch (Exception e) {
|
||||
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.SIGNATURE_FAILED,
|
||||
"O campo 'pdf' não contém dados Base64 válidos.");
|
||||
return;
|
||||
}
|
||||
|
||||
Optional<ManagedCertificate> 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();
|
||||
|
||||
if (promptListener != null) {
|
||||
try {
|
||||
CompletableFuture<Boolean> 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;
|
||||
}
|
||||
}
|
||||
|
||||
PdfSealTheme sealTheme;
|
||||
if (request.sealCustom() != null && !request.sealCustom().isEmpty()) {
|
||||
sealTheme = parseCustomSealTheme(request.sealCustom());
|
||||
} else if (request.sealThemeId() != null && !request.sealThemeId().isBlank()) {
|
||||
sealTheme = PdfSealThemeRegistry.findById(request.sealThemeId())
|
||||
.orElse(PdfSealThemeRegistry.getDefaultTheme());
|
||||
} else {
|
||||
sealTheme = PdfSealThemeRegistry.getDefaultTheme();
|
||||
}
|
||||
|
||||
logger.info("[CCSP REQ] Selo visual configurado: '{}' (custom={}, hasLogo={}, header='{}')",
|
||||
sealTheme.name(), sealTheme.isCustom(), sealTheme.customLogoBase64() != null, sealTheme.customHeader());
|
||||
|
||||
PdfSealPosition position = PdfSealPosition.BOTTOM_RIGHT;
|
||||
if (request.x() != null && request.y() != null) {
|
||||
position = PdfSealPosition.CUSTOM;
|
||||
} else if (request.position() != null) {
|
||||
try {
|
||||
position = PdfSealPosition.valueOf(request.position().toUpperCase());
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
PdfSealPageChoice pageChoice = PdfSealPageChoice.LAST_PAGE;
|
||||
if (request.pageChoice() != null) {
|
||||
try {
|
||||
pageChoice = PdfSealPageChoice.valueOf(request.pageChoice().toUpperCase());
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
CpfDisplayMode cpfMode = sealTheme.defaultCpfMode();
|
||||
if (request.cpfDisplayMode() != null) {
|
||||
try {
|
||||
cpfMode = CpfDisplayMode.valueOf(request.cpfDisplayMode().toUpperCase());
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
boolean includeDateTime = request.includeDateTime() != null
|
||||
? request.includeDateTime()
|
||||
: sealTheme.defaultIncludeDateTime();
|
||||
|
||||
boolean visibleSeal = request.visibleSeal() == null || Boolean.TRUE.equals(request.visibleSeal());
|
||||
double customScale = request.scale() != null && request.scale() > 0 ? request.scale() : 1.0;
|
||||
|
||||
PdfSignatureOptions options = new PdfSignatureOptions(
|
||||
cert.id(),
|
||||
null,
|
||||
visibleSeal,
|
||||
sealTheme,
|
||||
position,
|
||||
pageChoice,
|
||||
request.reason() != null ? request.reason() : "Assinatura Digital de Documento",
|
||||
request.location() != null ? request.location() : "Brasil",
|
||||
request.x(),
|
||||
request.y(),
|
||||
request.page(),
|
||||
cpfMode,
|
||||
includeDateTime,
|
||||
request.customExtraInfo(),
|
||||
customScale
|
||||
);
|
||||
|
||||
signLock.lock();
|
||||
try {
|
||||
byte[] signedPdfBytes = pdfSigningService.signPdf(pdfBytes, options);
|
||||
String signedPdfBase64 = Base64.getEncoder().encodeToString(signedPdfBytes);
|
||||
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||
byte[] shaBytes = md.digest(signedPdfBytes);
|
||||
String shaHex = bytesToHex(shaBytes);
|
||||
|
||||
Optional<SigningKeyMaterial> keyMat = certificateManager.getKeyMaterial(cert.id(), null);
|
||||
String certBase64 = null;
|
||||
List<String> chainBase64 = Collections.emptyList();
|
||||
if (keyMat.isPresent()) {
|
||||
try {
|
||||
certBase64 = Base64.getEncoder().encodeToString(keyMat.get().signingCertificate().getEncoded());
|
||||
chainBase64 = Arrays.stream(keyMat.get().certificateChain())
|
||||
.map(c -> {
|
||||
try {
|
||||
return Base64.getEncoder().encodeToString(c.getEncoded());
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
}
|
||||
})
|
||||
.filter(s -> !s.isEmpty())
|
||||
.toList();
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
String subjectName = X509CertificateUtils.extractHolderName(cert.commonName());
|
||||
String cpfCnpj = cert.cpf() != null ? cert.cpf() : cert.cnpj();
|
||||
|
||||
CcspResponse response = CcspResponse.pdfSuccess(
|
||||
request.requestId(),
|
||||
signedPdfBase64,
|
||||
shaHex,
|
||||
null,
|
||||
certBase64,
|
||||
chainBase64,
|
||||
"SHA256withRSA",
|
||||
subjectName,
|
||||
cpfCnpj,
|
||||
request.documentName() != null ? request.documentName() : "documento.pdf"
|
||||
);
|
||||
|
||||
sendJsonResponse(exchange, 200, response);
|
||||
|
||||
} catch (CertificateProviderException cpe) {
|
||||
sendCcspError(exchange, 400, request.requestId(), cpe.getErrorCode(), cpe.getMessage());
|
||||
} catch (Exception e) {
|
||||
logger.error("Falha ao assinar PDF: {}", e.getMessage(), e);
|
||||
sendCcspError(exchange, 500, request.requestId(), CcspErrorCode.SIGNATURE_FAILED,
|
||||
"Erro ao assinar PDF: " + e.getMessage());
|
||||
} finally {
|
||||
signLock.unlock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class SignXmlHandler 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;
|
||||
}
|
||||
|
||||
if (!request.isVersionSupported()) {
|
||||
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.COMMUNICATION_ERROR,
|
||||
"Versão do protocolo não suportada: " + request.version());
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.isExpired()) {
|
||||
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.REQUEST_EXPIRED,
|
||||
"A requisição de assinatura expirou.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validação anti-replay (Nonce obrigatório)
|
||||
if (request.nonce() == null || request.nonce().isBlank()) {
|
||||
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.REPLAY_DETECTED,
|
||||
"O parâmetro 'nonce' é obrigatório para proteção contra replay attack.");
|
||||
return;
|
||||
}
|
||||
boolean accepted = nonceManager.registerNonce(request.nonce());
|
||||
if (!accepted) {
|
||||
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.REPLAY_DETECTED,
|
||||
"Requisição duplicada detectada (replay prevenido).");
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
byte[] xmlBytes;
|
||||
if (request.xmlBase64() != null && !request.xmlBase64().isBlank()) {
|
||||
try {
|
||||
xmlBytes = Base64.getDecoder().decode(request.xmlBase64());
|
||||
} catch (Exception e) {
|
||||
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.SIGNATURE_FAILED,
|
||||
"O campo 'xml_base64' não contém dados válidos.");
|
||||
return;
|
||||
}
|
||||
} else if (request.xml() != null && !request.xml().isBlank()) {
|
||||
xmlBytes = request.xml().getBytes(StandardCharsets.UTF_8);
|
||||
} else {
|
||||
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.SIGNATURE_FAILED,
|
||||
"É obrigatório fornecer o XML em texto ('xml') ou Base64 ('xml_base64').");
|
||||
return;
|
||||
}
|
||||
|
||||
Optional<ManagedCertificate> 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();
|
||||
|
||||
if (promptListener != null) {
|
||||
try {
|
||||
CompletableFuture<Boolean> 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 XML: {}", e.getMessage());
|
||||
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.USER_CANCELLED,
|
||||
"Confirmação da assinatura expirou ou foi cancelada.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
XmlSignatureProfile profile = XmlSignatureProfile.XADES_BES;
|
||||
if (request.profile() != null) {
|
||||
try {
|
||||
profile = XmlSignatureProfile.valueOf(request.profile().toUpperCase());
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
String algo = request.algorithm() != null && !request.algorithm().isBlank()
|
||||
? request.algorithm()
|
||||
: XmlSignatureOptions.DEFAULT_ALGORITHM;
|
||||
|
||||
XmlSignatureOptions options = new XmlSignatureOptions(
|
||||
cert.id(),
|
||||
null,
|
||||
request.targetElementTag(),
|
||||
request.targetElementId(),
|
||||
algo,
|
||||
profile
|
||||
);
|
||||
|
||||
signLock.lock();
|
||||
try {
|
||||
byte[] signedXmlBytes = xmlSigningService.signXml(xmlBytes, options);
|
||||
String signedXmlStr = new String(signedXmlBytes, StandardCharsets.UTF_8);
|
||||
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||
byte[] shaBytes = md.digest(signedXmlBytes);
|
||||
String shaHex = bytesToHex(shaBytes);
|
||||
|
||||
Optional<SigningKeyMaterial> keyMat = certificateManager.getKeyMaterial(cert.id(), null);
|
||||
String certBase64 = null;
|
||||
List<String> chainBase64 = Collections.emptyList();
|
||||
if (keyMat.isPresent()) {
|
||||
try {
|
||||
certBase64 = Base64.getEncoder().encodeToString(keyMat.get().signingCertificate().getEncoded());
|
||||
chainBase64 = Arrays.stream(keyMat.get().certificateChain())
|
||||
.map(c -> {
|
||||
try {
|
||||
return Base64.getEncoder().encodeToString(c.getEncoded());
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
}
|
||||
})
|
||||
.filter(s -> !s.isEmpty())
|
||||
.toList();
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
String subjectName = X509CertificateUtils.extractHolderName(cert.commonName());
|
||||
String cpfCnpj = cert.cpf() != null ? cert.cpf() : cert.cnpj();
|
||||
|
||||
CcspResponse response = CcspResponse.xmlSuccess(
|
||||
request.requestId(),
|
||||
signedXmlStr,
|
||||
shaHex,
|
||||
null,
|
||||
certBase64,
|
||||
chainBase64,
|
||||
algo,
|
||||
subjectName,
|
||||
cpfCnpj,
|
||||
request.documentName() != null ? request.documentName() : "documento.xml"
|
||||
);
|
||||
|
||||
sendJsonResponse(exchange, 200, response);
|
||||
|
||||
} catch (CertificateProviderException cpe) {
|
||||
sendCcspError(exchange, 400, request.requestId(), cpe.getErrorCode(), cpe.getMessage());
|
||||
} catch (Exception e) {
|
||||
logger.error("Falha ao assinar XML: {}", e.getMessage(), e);
|
||||
sendCcspError(exchange, 500, request.requestId(), CcspErrorCode.SIGNATURE_FAILED,
|
||||
"Erro ao assinar XML: " + e.getMessage());
|
||||
} finally {
|
||||
signLock.unlock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class SealsHandler 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;
|
||||
}
|
||||
|
||||
List<Map<String, Object>> sealList = PdfSealThemeRegistry.listThemes().stream().map(t -> {
|
||||
Map<String, Object> dto = new HashMap<>();
|
||||
dto.put("id", t.id());
|
||||
dto.put("name", t.name());
|
||||
dto.put("description", t.description());
|
||||
dto.put("width", t.width());
|
||||
dto.put("height", t.height());
|
||||
dto.put("primary_color", t.primaryColor());
|
||||
dto.put("secondary_color", t.secondaryColor());
|
||||
dto.put("background_color", t.backgroundColor());
|
||||
dto.put("border_color", t.borderColor());
|
||||
dto.put("text_color", t.textColor());
|
||||
dto.put("icon_style", t.iconStyle());
|
||||
dto.put("is_custom", t.isCustom());
|
||||
dto.put("default_cpf_mode", t.defaultCpfMode() != null ? t.defaultCpfMode().name() : "FULL");
|
||||
dto.put("default_include_date_time", t.defaultIncludeDateTime());
|
||||
return dto;
|
||||
}).toList();
|
||||
|
||||
Map<String, Object> resp = new HashMap<>();
|
||||
resp.put("version", CcspRequest.PROTOCOL_VERSION);
|
||||
resp.put("status", "success");
|
||||
resp.put("default_seal_id", PdfSealThemeRegistry.getDefaultTheme().id());
|
||||
resp.put("seals", sealList);
|
||||
resp.put("themes", sealList);
|
||||
|
||||
sendJsonResponse(exchange, 200, resp);
|
||||
}
|
||||
}
|
||||
|
||||
private static String getMapString(Map<String, Object> map, String defaultVal, String... keys) {
|
||||
if (map == null) return defaultVal;
|
||||
for (String k : keys) {
|
||||
Object v = map.get(k);
|
||||
if (v != null && !v.toString().isBlank()) {
|
||||
return v.toString().trim();
|
||||
}
|
||||
}
|
||||
return defaultVal;
|
||||
}
|
||||
|
||||
private static int getMapInt(Map<String, Object> map, int defaultVal, String... keys) {
|
||||
if (map == null) return defaultVal;
|
||||
for (String k : keys) {
|
||||
Object v = map.get(k);
|
||||
if (v instanceof Number n) {
|
||||
return n.intValue();
|
||||
} else if (v != null) {
|
||||
try {
|
||||
return Integer.parseInt(v.toString().trim());
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
}
|
||||
return defaultVal;
|
||||
}
|
||||
|
||||
private PdfSealTheme parseCustomSealTheme(Map<String, Object> map) {
|
||||
if (map == null || map.isEmpty()) {
|
||||
return PdfSealThemeRegistry.getDefaultTheme();
|
||||
}
|
||||
String id = "custom-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
String name = getMapString(map, "Selo Personalizado", "name", "theme_name", "title");
|
||||
String description = getMapString(map, "Selo customizado via aplicação web", "description", "desc");
|
||||
int width = getMapInt(map, 320, "width", "w");
|
||||
int height = getMapInt(map, 82, "height", "h");
|
||||
String primaryColor = getMapString(map, "#0d47a1", "primaryColor", "primary_color");
|
||||
String secondaryColor = getMapString(map, "#0284c7", "secondaryColor", "secondary_color");
|
||||
String backgroundColor = getMapString(map, "#ffffff", "backgroundColor", "background_color");
|
||||
String borderColor = getMapString(map, "#cbd5e1", "borderColor", "border_color");
|
||||
String textColor = getMapString(map, "#0f172a", "textColor", "text_color");
|
||||
String customHeader = getMapString(map, null, "customHeader", "custom_header", "title");
|
||||
String customFooter = getMapString(map, null, "customFooter", "custom_footer");
|
||||
String iconStyle = getMapString(map, "shield", "iconStyle", "icon_style");
|
||||
String customLogoBase64 = getMapString(map, null, "customLogoBase64", "custom_logo_base64", "logo", "logoBase64");
|
||||
String customFieldLabel = getMapString(map, null, "customFieldLabel", "custom_field_label");
|
||||
String customFieldValue = getMapString(map, null, "customFieldValue", "custom_field_value");
|
||||
|
||||
CpfDisplayMode cpfMode = CpfDisplayMode.FULL;
|
||||
String rawCpfMode = getMapString(map, null, "defaultCpfMode", "default_cpf_mode", "cpfMode", "cpf_mode");
|
||||
if (rawCpfMode != null) {
|
||||
try {
|
||||
cpfMode = CpfDisplayMode.valueOf(rawCpfMode.toUpperCase());
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
boolean includeDateTime = true;
|
||||
Object dtObj = map.containsKey("defaultIncludeDateTime") ? map.get("defaultIncludeDateTime")
|
||||
: (map.containsKey("default_include_date_time") ? map.get("default_include_date_time")
|
||||
: (map.containsKey("includeDateTime") ? map.get("includeDateTime")
|
||||
: map.get("include_date_time")));
|
||||
if (dtObj instanceof Boolean b) {
|
||||
includeDateTime = b;
|
||||
} else if (dtObj != null) {
|
||||
includeDateTime = Boolean.parseBoolean(dtObj.toString());
|
||||
}
|
||||
|
||||
return new PdfSealTheme(
|
||||
id, name, description, width, height,
|
||||
primaryColor, secondaryColor, backgroundColor, borderColor, textColor,
|
||||
null, customHeader, customFooter, iconStyle,
|
||||
cpfMode, includeDateTime, true, customLogoBase64,
|
||||
customFieldLabel, customFieldValue
|
||||
);
|
||||
}
|
||||
|
||||
private static String bytesToHex(byte[] bytes) {
|
||||
if (bytes == null) return "";
|
||||
StringBuilder sb = new StringBuilder(bytes.length * 2);
|
||||
for (byte b : bytes) {
|
||||
sb.append(String.format("%02x", b));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Utilitários de Segurança e HTTP
|
||||
// =========================================================================
|
||||
@@ -486,25 +1045,92 @@ public class CcspServer {
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean handleCors(HttpExchange exchange) throws IOException {
|
||||
String origin = getOrigin(exchange);
|
||||
if (origin == null || origin.isBlank()) {
|
||||
origin = "*";
|
||||
private boolean validateHost(HttpExchange exchange) throws IOException {
|
||||
String hostHeader = exchange.getRequestHeaders().getFirst("Host");
|
||||
if (hostHeader == null || hostHeader.isBlank()) {
|
||||
logger.warn("Requisição CCSP rejeitada: cabeçalho 'Host' ausente.");
|
||||
sendErrorResponse(exchange, 403, "INVALID_HOST", "Cabeçalho Host é obrigatório.");
|
||||
return false;
|
||||
}
|
||||
|
||||
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");
|
||||
String rawHost = hostHeader.trim();
|
||||
String host;
|
||||
if (rawHost.startsWith("[")) {
|
||||
int closingBracket = rawHost.indexOf(']');
|
||||
if (closingBracket != -1) {
|
||||
host = rawHost.substring(0, closingBracket + 1);
|
||||
} else {
|
||||
host = rawHost;
|
||||
}
|
||||
} else {
|
||||
int colonIdx = rawHost.indexOf(':');
|
||||
host = colonIdx != -1 ? rawHost.substring(0, colonIdx) : rawHost;
|
||||
}
|
||||
|
||||
boolean isAllowedHost = "127.0.0.1".equalsIgnoreCase(host)
|
||||
|| "localhost".equalsIgnoreCase(host)
|
||||
|| "[::1]".equalsIgnoreCase(host);
|
||||
|
||||
if (!isAllowedHost) {
|
||||
logger.warn("ALERTA DE SEGURANÇA: Requisição CCSP rejeitada por suspeita de DNS Rebinding. Host: '{}'", hostHeader);
|
||||
sendErrorResponse(exchange, 403, CcspErrorCode.INVALID_ORIGIN.name(), "Host não autorizado para loopback local.");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean handleCors(HttpExchange exchange) throws IOException {
|
||||
if (!validateHost(exchange)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
String path = exchange.getRequestURI().getPath();
|
||||
String origin = getOrigin(exchange);
|
||||
|
||||
if (origin == null || origin.isBlank()) {
|
||||
if ("OPTIONS".equalsIgnoreCase(exchange.getRequestMethod())) {
|
||||
exchange.sendResponseHeaders(204, -1);
|
||||
exchange.close();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Determina se a origem ou rota é elegível para CORS
|
||||
boolean isHandshakePath = path.endsWith("/status") || path.endsWith("/authorize");
|
||||
boolean isAuthorizedOrigin = applicationRegistry.findByOrigin(origin).isPresent() || isLocalOrigin(origin);
|
||||
|
||||
if (isHandshakePath || isAuthorizedOrigin) {
|
||||
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, Accept");
|
||||
exchange.getResponseHeaders().set("Access-Control-Allow-Private-Network", "true");
|
||||
exchange.getResponseHeaders().set("Access-Control-Max-Age", "86400");
|
||||
}
|
||||
|
||||
if ("OPTIONS".equalsIgnoreCase(exchange.getRequestMethod())) {
|
||||
exchange.sendResponseHeaders(204, -1);
|
||||
if (isHandshakePath || isAuthorizedOrigin) {
|
||||
exchange.sendResponseHeaders(204, -1);
|
||||
} else {
|
||||
exchange.sendResponseHeaders(403, -1);
|
||||
}
|
||||
exchange.close();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean isLocalOrigin(String origin) {
|
||||
if (origin == null || origin.isBlank()) return false;
|
||||
try {
|
||||
URI uri = URI.create(origin);
|
||||
String host = uri.getHost();
|
||||
return "localhost".equalsIgnoreCase(host) || "127.0.0.1".equals(host);
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private String getOrigin(HttpExchange exchange) {
|
||||
String origin = exchange.getRequestHeaders().getFirst("Origin");
|
||||
if (origin == null || origin.isBlank()) {
|
||||
@@ -531,6 +1157,22 @@ public class CcspServer {
|
||||
try (OutputStream os = exchange.getResponseBody()) {
|
||||
os.write(bytes);
|
||||
}
|
||||
|
||||
String path = exchange.getRequestURI().getPath();
|
||||
if (data instanceof CcspResponse resp) {
|
||||
if ("signed".equalsIgnoreCase(resp.status())) {
|
||||
logger.info("[CCSP RESP] {} -> HTTP {} | ReqId: {} | Status: signed | Resposta: {} bytes | SHA-256: {}",
|
||||
path, statusCode, resp.requestId(), bytes.length, resp.sha256() != null ? resp.sha256() : "n/a");
|
||||
} else if ("error".equalsIgnoreCase(resp.status())) {
|
||||
logger.warn("[CCSP RESP ERROR] {} -> HTTP {} | ReqId: {} | Code: {} | Msg: {}",
|
||||
path, statusCode, resp.requestId(), resp.errorCode(), resp.message());
|
||||
} else {
|
||||
logger.info("[CCSP RESP] {} -> HTTP {} | ReqId: {} | Status: {} | Resposta: {} bytes",
|
||||
path, statusCode, resp.requestId(), resp.status(), bytes.length);
|
||||
}
|
||||
} else {
|
||||
logger.info("[CCSP RESP] {} -> HTTP {} | Resposta: {} bytes", path, statusCode, bytes.length);
|
||||
}
|
||||
}
|
||||
|
||||
private void sendErrorResponse(HttpExchange exchange, int httpStatus, String code, String message) throws IOException {
|
||||
|
||||
@@ -2,6 +2,7 @@ package br.com.cybercore.signer.core.provider;
|
||||
|
||||
import br.com.cybercore.signer.core.domain.CertificateStatus;
|
||||
import br.com.cybercore.signer.core.domain.ManagedCertificate;
|
||||
import br.com.cybercore.signer.core.security.SmartCardWatcher;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
@@ -19,26 +20,82 @@ 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.
|
||||
* Implementa cache sob demanda com TTL e invalidação imediata em eventos de hardware (SmartCardWatcher).
|
||||
*/
|
||||
public class CertificateManager {
|
||||
public class CertificateManager implements SmartCardWatcher.CardEventListener {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(CertificateManager.class);
|
||||
private static final long DEFAULT_CACHE_TTL_MS = 3_000L;
|
||||
|
||||
private final long cacheTtlMs;
|
||||
private final List<CertificateProvider> providers = new CopyOnWriteArrayList<>();
|
||||
private final Map<String, CertificateProvider> certificateToProviderMap = new ConcurrentHashMap<>();
|
||||
private final Map<String, ManagedCertificate> cachedCertificates = new ConcurrentHashMap<>();
|
||||
|
||||
private volatile long lastDiscoveryTimestamp = 0L;
|
||||
private volatile List<ManagedCertificate> cachedDiscoveredList = Collections.emptyList();
|
||||
|
||||
public CertificateManager() {
|
||||
this(DEFAULT_CACHE_TTL_MS);
|
||||
}
|
||||
|
||||
public CertificateManager(long cacheTtlMs) {
|
||||
this.cacheTtlMs = Math.max(0, cacheTtlMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalida explicitamente o cache em memória de certificados descobertos.
|
||||
*/
|
||||
public void invalidateCache() {
|
||||
lastDiscoveryTimestamp = 0L;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vincula um SmartCardWatcher para invalidar o cache de certificados automaticamente
|
||||
* assim que um token/cartão físico for inserido ou removido.
|
||||
*/
|
||||
public void attachSmartCardWatcher(SmartCardWatcher watcher) {
|
||||
if (watcher != null) {
|
||||
watcher.addListener(this);
|
||||
logger.info("SmartCardWatcher vinculado com sucesso ao CertificateManager para invalidação automática de cache.");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCardInserted(String terminalName) {
|
||||
logger.info("Notificação de hardware: Smart Card inserido no terminal '{}'. Invalidando cache de certificados.", terminalName);
|
||||
invalidateCache();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCardRemoved(String terminalName) {
|
||||
logger.info("Notificação de hardware: Smart Card removido do terminal '{}'. Invalidando cache de certificados.", terminalName);
|
||||
invalidateCache();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cria e inicializa uma instância padrão do CertificateManager com os provedores nativos da plataforma.
|
||||
* Suporta chaves de diagnóstico para isolamento de problemas de middleware:
|
||||
* -Dcybercore.diag.no-wincert=true (não inicializa Windows Certificate Store)
|
||||
* -Dcybercore.diag.no-pkcs11=true (não inicializa PKCS#11 / DLLs nativas)
|
||||
*/
|
||||
public static CertificateManager createDefault() {
|
||||
CertificateManager manager = new CertificateManager();
|
||||
manager.registerProvider(new WindowsCertificateStoreProvider());
|
||||
|
||||
if (Boolean.getBoolean("cybercore.diag.no-wincert")) {
|
||||
logger.warn("Diagnóstico: Provedor Windows Certificate Store DESATIVADO via -Dcybercore.diag.no-wincert=true");
|
||||
} else {
|
||||
manager.registerProvider(new WindowsCertificateStoreProvider());
|
||||
}
|
||||
|
||||
manager.registerProvider(new Pkcs12CertificateProvider());
|
||||
manager.registerProvider(new Pkcs11CertificateProvider());
|
||||
|
||||
if (Boolean.getBoolean("cybercore.diag.no-pkcs11")) {
|
||||
logger.warn("Diagnóstico: Provedor PKCS#11 DESATIVADO via -Dcybercore.diag.no-pkcs11=true");
|
||||
} else {
|
||||
manager.registerProvider(new Pkcs11CertificateProvider());
|
||||
}
|
||||
|
||||
return manager;
|
||||
}
|
||||
|
||||
@@ -50,6 +107,7 @@ public class CertificateManager {
|
||||
// Evita duplicidade por providerId
|
||||
providers.removeIf(p -> p.getProviderId().equalsIgnoreCase(provider.getProviderId()));
|
||||
providers.add(provider);
|
||||
invalidateCache();
|
||||
logger.info("Provedor registrado no CertificateManager: {} [{}] (disponível={})",
|
||||
provider.getDisplayName(), provider.getProviderId(), provider.isAvailable());
|
||||
}
|
||||
@@ -61,6 +119,7 @@ public class CertificateManager {
|
||||
if (providerId != null) {
|
||||
providers.removeIf(p -> p.getProviderId().equalsIgnoreCase(providerId));
|
||||
certificateToProviderMap.values().removeIf(p -> p.getProviderId().equalsIgnoreCase(providerId));
|
||||
invalidateCache();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,9 +129,23 @@ public class CertificateManager {
|
||||
|
||||
/**
|
||||
* Executa a descoberta de certificados em todos os provedores operacionais.
|
||||
* Consolida os resultados, atualiza o mapeamento de provedores e ordena por relevância/disponibilidade.
|
||||
* Utiliza cache sob demanda se a última descoberta ocorreu dentro da janela de TTL.
|
||||
*/
|
||||
public synchronized List<ManagedCertificate> discoverCertificates() {
|
||||
return discoverCertificates(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Executa a descoberta de certificados em todos os provedores operacionais, com opção
|
||||
* de forçar o recarregamento ignorando o TTL do cache.
|
||||
*/
|
||||
public synchronized List<ManagedCertificate> discoverCertificates(boolean forceRefresh) {
|
||||
long now = System.currentTimeMillis();
|
||||
if (!forceRefresh && (now - lastDiscoveryTimestamp < cacheTtlMs) && !cachedDiscoveredList.isEmpty()) {
|
||||
logger.debug("Retornando lista de certificados a partir do cache sob demanda (TTL: {} ms).", cacheTtlMs);
|
||||
return cachedDiscoveredList;
|
||||
}
|
||||
|
||||
logger.info("Iniciando descoberta de certificados digitais nos provedores ativos...");
|
||||
|
||||
certificateToProviderMap.clear();
|
||||
@@ -104,8 +177,11 @@ public class CertificateManager {
|
||||
.comparing((ManagedCertificate c) -> c.status() == CertificateStatus.AVAILABLE ? 0 : 1)
|
||||
.thenComparing(ManagedCertificate::commonName, String.CASE_INSENSITIVE_ORDER));
|
||||
|
||||
cachedDiscoveredList = Collections.unmodifiableList(discovered);
|
||||
lastDiscoveryTimestamp = System.currentTimeMillis();
|
||||
|
||||
logger.info("Descoberta concluída: {} certificado(s) total no agente.", discovered.size());
|
||||
return Collections.unmodifiableList(discovered);
|
||||
return cachedDiscoveredList;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -182,6 +258,7 @@ public class CertificateManager {
|
||||
* Solicita recarregamento de todos os provedores cadastrados.
|
||||
*/
|
||||
public void refreshAll() {
|
||||
invalidateCache();
|
||||
for (CertificateProvider provider : providers) {
|
||||
try {
|
||||
provider.refresh();
|
||||
@@ -189,6 +266,6 @@ public class CertificateManager {
|
||||
logger.warn("Erro ao recarregar provedor '{}': {}", provider.getProviderId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
discoverCertificates();
|
||||
discoverCertificates(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,10 +46,14 @@ public class Pkcs11CertificateProvider implements CertificateProvider {
|
||||
"C:/Windows/System32/aetpkss1.dll", // SafeSign AET
|
||||
"C:/Windows/System32/acospkcs11.dll", // ACO / Giesecke & Devrient
|
||||
"C:/Windows/System32/opensc-pkcs11.dll",// OpenSC Windows
|
||||
"C:/SoftHSM2/lib/softhsm2.dll", // SoftHSM2 Windows
|
||||
// Linux
|
||||
"/usr/lib/x86_64-linux-gnu/opensc-pkcs11.so",
|
||||
"/usr/lib/opensc-pkcs11.so",
|
||||
"/usr/lib/libaetpkss.so"
|
||||
"/usr/lib/libaetpkss.so",
|
||||
"/usr/lib/softhsm/libsofthsm2.so", // SoftHSM2 Debian/Ubuntu
|
||||
"/usr/lib/x86_64-linux-gnu/softhsm/libsofthsm2.so", // SoftHSM2 Multiarch
|
||||
"/usr/local/lib/softhsm/libsofthsm2.so"
|
||||
};
|
||||
|
||||
private Provider sunPkcs11Provider;
|
||||
@@ -79,6 +83,50 @@ public class Pkcs11CertificateProvider implements CertificateProvider {
|
||||
this.keyStore = keyStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inicializa dinamicamente o provider SunPKCS11 em memória para uma biblioteca específica,
|
||||
* slot e PIN, sem expor ou gravar credenciais em disco.
|
||||
*/
|
||||
public synchronized void initializeWithPin(String libraryPath, int slotIndex, char[] pin) throws CertificateProviderException {
|
||||
Objects.requireNonNull(libraryPath, "Caminho da biblioteca PKCS#11 não pode ser nulo.");
|
||||
File f = new File(libraryPath);
|
||||
if (!f.exists() || !f.canRead()) {
|
||||
throw new CertificateProviderException(
|
||||
CcspErrorCode.PKCS11_ERROR,
|
||||
"Biblioteca PKCS#11 não encontrada ou sem permissão de leitura."
|
||||
);
|
||||
}
|
||||
try {
|
||||
Provider prototype = Security.getProvider("SunPKCS11");
|
||||
if (prototype == null) {
|
||||
throw new CertificateProviderException(
|
||||
CcspErrorCode.PROVIDER_NOT_AVAILABLE,
|
||||
"Provider SunPKCS11 (jdk.crypto.cryptoki) não disponível nesta JVM."
|
||||
);
|
||||
}
|
||||
String config = "--name = CyberCore-PKCS11-" + System.currentTimeMillis() + "\n"
|
||||
+ "library = " + libraryPath + "\n"
|
||||
+ "slotListIndex = " + slotIndex + "\n";
|
||||
Provider configured = prototype.configure(config);
|
||||
KeyStore ks = KeyStore.getInstance("PKCS11", configured);
|
||||
ks.load(null, pin);
|
||||
|
||||
this.sunPkcs11Provider = configured;
|
||||
this.keyStore = ks;
|
||||
this.detectedLibraryPath = libraryPath;
|
||||
logger.info("Provedor SunPKCS11 inicializado com sucesso para o slot {}", slotIndex);
|
||||
} catch (CertificateProviderException cpe) {
|
||||
throw cpe;
|
||||
} catch (Exception e) {
|
||||
logger.error("Falha ao inicializar o provedor SunPKCS11: {}", e.getMessage(), e);
|
||||
throw new CertificateProviderException(
|
||||
CcspErrorCode.PKCS11_ERROR,
|
||||
"Falha ao inicializar o dispositivo PKCS#11: " + e.getMessage(),
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getProviderId() {
|
||||
return PROVIDER_ID;
|
||||
|
||||
@@ -25,10 +25,12 @@ 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 String HMAC_FILE_SUFFIX = ".hmac";
|
||||
|
||||
private static final ApplicationRegistry INSTANCE = new ApplicationRegistry();
|
||||
|
||||
private final Path storagePath;
|
||||
private final HmacIntegrityManager integrityManager;
|
||||
private final Map<String, AuthorizedApplication> applications = new ConcurrentHashMap<>();
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@@ -37,15 +39,28 @@ public final class ApplicationRegistry {
|
||||
}
|
||||
|
||||
public ApplicationRegistry() {
|
||||
this(resolveDefaultStoragePath());
|
||||
this(resolveDefaultStoragePath(), new HmacIntegrityManager());
|
||||
}
|
||||
|
||||
public ApplicationRegistry(Path storagePath) {
|
||||
this(storagePath, new HmacIntegrityManager(resolveKeyPathForStorage(storagePath)));
|
||||
}
|
||||
|
||||
public ApplicationRegistry(Path storagePath, HmacIntegrityManager integrityManager) {
|
||||
this.storagePath = storagePath;
|
||||
this.integrityManager = integrityManager != null ? integrityManager : new HmacIntegrityManager();
|
||||
this.objectMapper = new ObjectMapper().registerModule(new JavaTimeModule());
|
||||
load();
|
||||
}
|
||||
|
||||
private static Path resolveKeyPathForStorage(Path storagePath) {
|
||||
if (storagePath == null) {
|
||||
return null;
|
||||
}
|
||||
Path parent = storagePath.getParent();
|
||||
return parent != null ? parent.resolve(".vault_key") : null;
|
||||
}
|
||||
|
||||
private static Path resolveDefaultStoragePath() {
|
||||
String userHome = System.getProperty("user.home", ".");
|
||||
return Path.of(userHome, ".cybercore", DEFAULT_FILE_NAME);
|
||||
@@ -108,8 +123,15 @@ public final class ApplicationRegistry {
|
||||
return Collections.unmodifiableList(new ArrayList<>(applications.values()));
|
||||
}
|
||||
|
||||
private Path resolveHmacPath() {
|
||||
if (storagePath == null) {
|
||||
return null;
|
||||
}
|
||||
return Path.of(storagePath.toString() + HMAC_FILE_SUFFIX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Carrega as aplicações persistidas em disco.
|
||||
* Carrega as aplicações persistidas em disco verificando a integridade por HMAC-SHA256.
|
||||
*/
|
||||
public synchronized void load() {
|
||||
if (storagePath == null || !Files.exists(storagePath)) {
|
||||
@@ -118,22 +140,42 @@ public final class ApplicationRegistry {
|
||||
}
|
||||
|
||||
try {
|
||||
byte[] fileBytes = Files.readAllBytes(storagePath);
|
||||
Path hmacPath = resolveHmacPath();
|
||||
|
||||
if (hmacPath != null && Files.exists(hmacPath)) {
|
||||
String expectedHmac = Files.readString(hmacPath).trim();
|
||||
boolean valid = integrityManager.verifyHmac(fileBytes, expectedHmac);
|
||||
if (!valid) {
|
||||
logger.error("ALERTA DE AUDITORIA: Adulteração detectada no arquivo de aplicações autorizadas {}. O HMAC diverge do esperado. Todas as autorizações foram invalidadas.", storagePath);
|
||||
applications.clear();
|
||||
return;
|
||||
}
|
||||
} else if (hmacPath != null) {
|
||||
// Se existe o JSON mas não o HMAC correspondente (migração/inicialização segura)
|
||||
logger.warn("ALERTA DE AUDITORIA: Arquivo {} sem assinatura HMAC de integridade. Calculando e gerando assinatura inicial.", storagePath);
|
||||
String newHmac = integrityManager.computeHmacHex(fileBytes);
|
||||
Files.writeString(hmacPath, newHmac);
|
||||
HmacIntegrityManager.restrictPermissions(hmacPath);
|
||||
}
|
||||
|
||||
List<AuthorizedApplication> list = objectMapper.readValue(
|
||||
storagePath.toFile(),
|
||||
fileBytes,
|
||||
new TypeReference<List<AuthorizedApplication>>() {}
|
||||
);
|
||||
applications.clear();
|
||||
for (AuthorizedApplication app : list) {
|
||||
applications.put(app.applicationId(), app);
|
||||
}
|
||||
logger.info("Carregadas {} aplicação(ões) autorizada(s) de {}", applications.size(), storagePath);
|
||||
logger.info("Carregadas {} aplicação(ões) autorizada(s) íntegras de {}", applications.size(), storagePath);
|
||||
} catch (Exception e) {
|
||||
logger.warn("Falha ao ler aplicações autorizadas de {}: {}", storagePath, e.getMessage());
|
||||
applications.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Salva as aplicações atuais em disco.
|
||||
* Salva as aplicações atuais em disco com assinatura HMAC e permissões de acesso restritas.
|
||||
*/
|
||||
public synchronized void save() {
|
||||
if (storagePath == null) {
|
||||
@@ -144,12 +186,23 @@ public final class ApplicationRegistry {
|
||||
Path parent = storagePath.getParent();
|
||||
if (parent != null && !Files.exists(parent)) {
|
||||
Files.createDirectories(parent);
|
||||
HmacIntegrityManager.restrictPermissions(parent);
|
||||
}
|
||||
objectMapper.writerWithDefaultPrettyPrinter().writeValue(
|
||||
storagePath.toFile(),
|
||||
|
||||
byte[] jsonBytes = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(
|
||||
new ArrayList<>(applications.values())
|
||||
);
|
||||
logger.debug("Aplicações autorizadas salvas com sucesso em {}", storagePath);
|
||||
Files.write(storagePath, jsonBytes);
|
||||
HmacIntegrityManager.restrictPermissions(storagePath);
|
||||
|
||||
Path hmacPath = resolveHmacPath();
|
||||
if (hmacPath != null) {
|
||||
String hmacHex = integrityManager.computeHmacHex(jsonBytes);
|
||||
Files.writeString(hmacPath, hmacHex);
|
||||
HmacIntegrityManager.restrictPermissions(hmacPath);
|
||||
}
|
||||
|
||||
logger.debug("Aplicações autorizadas e HMAC salvos com sucesso em {}", storagePath);
|
||||
} catch (Exception e) {
|
||||
logger.error("Erro ao salvar aplicações autorizadas em {}: {}", storagePath, e.getMessage());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
package br.com.cybercore.signer.core.security;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.FileSystems;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.PosixFilePermission;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.EnumSet;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Gerenciador de integridade criptográfica por HMAC-SHA256 para arquivos de configuração e
|
||||
* autorizações locais, com custódia de chave mestre isolada e permissões de arquivo restritas.
|
||||
*/
|
||||
public class HmacIntegrityManager {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(HmacIntegrityManager.class);
|
||||
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
|
||||
private static final String HMAC_ALGORITHM = "HmacSHA256";
|
||||
private static final int KEY_SIZE_BYTES = 32;
|
||||
private static final String DEFAULT_KEY_FILE = ".vault_key";
|
||||
|
||||
private final Path keyPath;
|
||||
private byte[] secretKey;
|
||||
|
||||
public HmacIntegrityManager() {
|
||||
this(resolveDefaultKeyPath());
|
||||
}
|
||||
|
||||
public HmacIntegrityManager(Path keyPath) {
|
||||
this.keyPath = keyPath;
|
||||
initSecretKey();
|
||||
}
|
||||
|
||||
private static Path resolveDefaultKeyPath() {
|
||||
String userHome = System.getProperty("user.home", ".");
|
||||
return Path.of(userHome, ".cybercore", DEFAULT_KEY_FILE);
|
||||
}
|
||||
|
||||
private synchronized void initSecretKey() {
|
||||
if (keyPath == null) {
|
||||
byte[] ephemeralKey = new byte[KEY_SIZE_BYTES];
|
||||
SECURE_RANDOM.nextBytes(ephemeralKey);
|
||||
this.secretKey = ephemeralKey;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (Files.exists(keyPath)) {
|
||||
byte[] loaded = Files.readAllBytes(keyPath);
|
||||
if (loaded.length >= KEY_SIZE_BYTES) {
|
||||
this.secretKey = loaded;
|
||||
restrictPermissions(keyPath);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Gera nova chave criptográfica forte
|
||||
byte[] newKey = new byte[KEY_SIZE_BYTES];
|
||||
SECURE_RANDOM.nextBytes(newKey);
|
||||
this.secretKey = newKey;
|
||||
|
||||
Path parent = keyPath.getParent();
|
||||
if (parent != null && !Files.exists(parent)) {
|
||||
Files.createDirectories(parent);
|
||||
restrictPermissions(parent);
|
||||
}
|
||||
|
||||
Files.write(keyPath, newKey);
|
||||
restrictPermissions(keyPath);
|
||||
logger.info("Chave mestre de integridade inicializada com permissões restritas em {}", keyPath);
|
||||
} catch (Exception e) {
|
||||
logger.warn("Falha ao persistir chave em {}. Usando chave em memória: {}", keyPath, e.getMessage());
|
||||
byte[] fallbackKey = new byte[KEY_SIZE_BYTES];
|
||||
SECURE_RANDOM.nextBytes(fallbackKey);
|
||||
this.secretKey = fallbackKey;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcula o HMAC-SHA256 dos dados informados e devolve em hexadecimal.
|
||||
*/
|
||||
public String computeHmacHex(byte[] data) {
|
||||
if (data == null) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
Mac mac = Mac.getInstance(HMAC_ALGORITHM);
|
||||
SecretKeySpec keySpec = new SecretKeySpec(secretKey, HMAC_ALGORITHM);
|
||||
mac.init(keySpec);
|
||||
byte[] hmacBytes = mac.doFinal(data);
|
||||
return HexFormat.of().formatHex(hmacBytes);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Erro ao calcular HMAC-SHA256: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica a integridade dos dados contra um HMAC esperado usando comparação em tempo constante
|
||||
* para mitigar timing attacks.
|
||||
*/
|
||||
public boolean verifyHmac(byte[] data, String expectedHex) {
|
||||
if (data == null || expectedHex == null || expectedHex.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
String calculatedHex = computeHmacHex(data);
|
||||
byte[] expectedBytes = expectedHex.trim().getBytes(StandardCharsets.UTF_8);
|
||||
byte[] calculatedBytes = calculatedHex.getBytes(StandardCharsets.UTF_8);
|
||||
return MessageDigest.isEqual(expectedBytes, calculatedBytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restringe as permissões do arquivo/diretório para acesso exclusivo do usuário atual (0600 / 0700).
|
||||
*/
|
||||
public static void restrictPermissions(Path path) {
|
||||
if (path == null || !Files.exists(path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
File file = path.toFile();
|
||||
// Proteção padrão Java SE (funciona em Windows e Unix)
|
||||
boolean ok = file.setReadable(false, false);
|
||||
ok = file.setReadable(true, true) && ok;
|
||||
ok = file.setWritable(false, false) && ok;
|
||||
ok = file.setWritable(true, true) && ok;
|
||||
ok = file.setExecutable(false, false) && ok;
|
||||
if (!ok) {
|
||||
logger.debug("Não foi possível ajustar todos os atributos em {}", path);
|
||||
}
|
||||
|
||||
// Permissões POSIX estritas (Linux / macOS)
|
||||
if (FileSystems.getDefault().supportedFileAttributeViews().contains("posix")) {
|
||||
try {
|
||||
boolean isDir = Files.isDirectory(path);
|
||||
Set<PosixFilePermission> permissions = isDir
|
||||
? EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE, PosixFilePermission.OWNER_EXECUTE)
|
||||
: EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE);
|
||||
Files.setPosixFilePermissions(path, permissions);
|
||||
} catch (IOException e) {
|
||||
logger.debug("Não foi possível aplicar atributos POSIX em {}: {}", path, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
*/
|
||||
public class NonceManager {
|
||||
|
||||
public static final Duration DEFAULT_CLOCK_SKEW = Duration.ofSeconds(30);
|
||||
|
||||
private final Duration windowDuration;
|
||||
private final ConcurrentHashMap<String, Instant> usedNonces = new ConcurrentHashMap<>();
|
||||
|
||||
@@ -21,6 +23,34 @@ public class NonceManager {
|
||||
this(Duration.ofSeconds(120));
|
||||
}
|
||||
|
||||
/**
|
||||
* Valida um timestamp de requisição em relação ao relógio local, considerando a janela
|
||||
* de validade e uma margem segura de clock skew (tolerância a desvio de relógio entre cliente e agente).
|
||||
*
|
||||
* @param requestTimestamp Instant da requisição informado pelo cliente.
|
||||
* @param clockSkew Tolerância de tolerância a skew de relógio.
|
||||
* @return {@code true} se o timestamp estiver na faixa válida permitida.
|
||||
*/
|
||||
public boolean isTimestampValid(Instant requestTimestamp, Duration clockSkew) {
|
||||
if (requestTimestamp == null) {
|
||||
return false;
|
||||
}
|
||||
Duration skew = clockSkew != null ? clockSkew : DEFAULT_CLOCK_SKEW;
|
||||
Instant now = Instant.now();
|
||||
Instant earliestAllowed = now.minus(windowDuration).minus(skew);
|
||||
Instant latestAllowed = now.plus(skew);
|
||||
|
||||
return !requestTimestamp.isBefore(earliestAllowed) && !requestTimestamp.isAfter(latestAllowed);
|
||||
}
|
||||
|
||||
public boolean isTimestampValid(Instant requestTimestamp) {
|
||||
return isTimestampValid(requestTimestamp, DEFAULT_CLOCK_SKEW);
|
||||
}
|
||||
|
||||
public Duration getWindowDuration() {
|
||||
return windowDuration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tenta registrar um nonce.
|
||||
*
|
||||
|
||||
@@ -3,6 +3,8 @@ package br.com.cybercore.signer.core.security;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Arrays;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
@@ -12,15 +14,19 @@ 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".
|
||||
* Os PINs são escopados por aplicação web (applicationId + certId) e possuem expiração por inatividade (TTL de 15 min).
|
||||
* 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 long SESSION_TTL_MINUTES = 15;
|
||||
|
||||
private static final PinSessionManager INSTANCE = new PinSessionManager();
|
||||
|
||||
private final Map<String, char[]> sessionPins = new ConcurrentHashMap<>();
|
||||
private record PinEntry(char[] pin, Instant lastAccessedAt) {}
|
||||
|
||||
private final Map<String, PinEntry> sessionPins = new ConcurrentHashMap<>();
|
||||
private volatile boolean alwaysPromptPin = false;
|
||||
|
||||
public PinSessionManager() {
|
||||
@@ -30,63 +36,107 @@ public class PinSessionManager {
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
private String buildKey(String applicationId, String certId) {
|
||||
String appId = (applicationId != null && !applicationId.isBlank()) ? applicationId.trim() : "default";
|
||||
return appId + "::" + certId.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Salva o PIN de um certificado/token para a sessão atual em memória volátil.
|
||||
* Salva o PIN associado a uma aplicação específica e certificado.
|
||||
*/
|
||||
public synchronized void savePin(String certId, char[] pin) {
|
||||
public synchronized void savePin(String applicationId, String certId, char[] pin) {
|
||||
Objects.requireNonNull(certId, "certId must not be null");
|
||||
String key = buildKey(applicationId, certId);
|
||||
if (pin == null || pin.length == 0) {
|
||||
clearPin(certId);
|
||||
clearPin(applicationId, 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);
|
||||
clearPin(applicationId, certId);
|
||||
sessionPins.put(key, new PinEntry(pin.clone(), Instant.now()));
|
||||
logger.debug("PIN salvo em sessão para app '{}' e cert '{}'. Tamanho: {} dígitos", applicationId, certId, pin.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recupera o PIN salvo em sessão para o certificado informado.
|
||||
* Salva o PIN no escopo padrão (compatibilidade).
|
||||
*/
|
||||
public synchronized char[] getPin(String certId) {
|
||||
public synchronized void savePin(String certId, char[] pin) {
|
||||
savePin("default", certId, pin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recupera o PIN salvo em sessão para a aplicação e certificado especificados.
|
||||
*/
|
||||
public synchronized char[] getPin(String applicationId, String certId) {
|
||||
if (certId == null || alwaysPromptPin) {
|
||||
return null;
|
||||
}
|
||||
char[] stored = sessionPins.get(certId);
|
||||
return stored != null ? stored.clone() : null;
|
||||
String key = buildKey(applicationId, certId);
|
||||
PinEntry entry = sessionPins.get(key);
|
||||
if (entry == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Verifica expiração por inatividade (TTL)
|
||||
if (Instant.now().isAfter(entry.lastAccessedAt().plus(SESSION_TTL_MINUTES, ChronoUnit.MINUTES))) {
|
||||
clearPin(applicationId, certId);
|
||||
logger.info("PIN da sessão expirou por inatividade (TTL {} min) para app '{}' e cert '{}'.", SESSION_TTL_MINUTES, applicationId, certId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Atualiza timestamp de último acesso
|
||||
sessionPins.put(key, new PinEntry(entry.pin(), Instant.now()));
|
||||
return entry.pin().clone();
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica se existe um PIN válido salvo nesta sessão para o certificado.
|
||||
* Recupera o PIN no escopo padrão (compatibilidade).
|
||||
*/
|
||||
public synchronized char[] getPin(String certId) {
|
||||
return getPin("default", certId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica se existe um PIN válido salvo nesta sessão para a aplicação e certificado.
|
||||
*/
|
||||
public synchronized boolean hasPin(String applicationId, String certId) {
|
||||
return getPin(applicationId, certId) != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica se existe um PIN no escopo padrão (compatibilidade).
|
||||
*/
|
||||
public synchronized boolean hasPin(String certId) {
|
||||
if (certId == null || alwaysPromptPin) {
|
||||
return false;
|
||||
}
|
||||
return sessionPins.containsKey(certId);
|
||||
return hasPin("default", certId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove o PIN salvo para um certificado específico, zerando a memória.
|
||||
* Remove o PIN salvo para uma aplicação e certificado específicos, zerando a memória.
|
||||
*/
|
||||
public synchronized void clearPin(String certId) {
|
||||
public synchronized void clearPin(String applicationId, String certId) {
|
||||
if (certId != null) {
|
||||
char[] old = sessionPins.remove(certId);
|
||||
if (old != null) {
|
||||
Arrays.fill(old, ' ');
|
||||
String key = buildKey(applicationId, certId);
|
||||
PinEntry old = sessionPins.remove(key);
|
||||
if (old != null && old.pin() != null) {
|
||||
Arrays.fill(old.pin(), ' ');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove o PIN no escopo padrão (compatibilidade).
|
||||
*/
|
||||
public synchronized void clearPin(String certId) {
|
||||
clearPin("default", certId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, ' ');
|
||||
for (PinEntry entry : sessionPins.values()) {
|
||||
if (entry != null && entry.pin() != null) {
|
||||
Arrays.fill(entry.pin(), ' ');
|
||||
}
|
||||
}
|
||||
sessionPins.clear();
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
package br.com.cybercore.signer.core.security;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.SecretKey;
|
||||
import javax.crypto.SecretKeyFactory;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.PBEKeySpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.io.FileOutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.PosixFileAttributeView;
|
||||
import java.nio.file.attribute.PosixFilePermission;
|
||||
import java.security.SecureRandom;
|
||||
import java.security.spec.KeySpec;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Armazenamento local seguro e criptografado de PINs/senhas de certificados.
|
||||
* <p>
|
||||
* Utiliza criptografia simétrica AES-256-GCM com chave derivada exclusivamente
|
||||
* a partir de semente aleatória protegida no disco local do usuário e atributos estáveis da máquina (PBKDF2WithHmacSHA256).
|
||||
* Os dados são gravados com permissões restritas em {@code ~/.cybercore/secure-pins.dat}.
|
||||
*/
|
||||
public final class SecurePinStorage {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(SecurePinStorage.class);
|
||||
|
||||
private static final String FILE_NAME = "secure-pins.dat";
|
||||
private static final String SALT_FILE_NAME = "secure-pins.salt";
|
||||
private static final String SEED_FILE_NAME = "secure-pins.seed";
|
||||
private static final int GCM_IV_LENGTH = 12;
|
||||
private static final int GCM_TAG_LENGTH = 128;
|
||||
private static final int PBKDF2_ITERATIONS = 65536;
|
||||
private static final int KEY_LENGTH_BITS = 256;
|
||||
|
||||
private static final SecurePinStorage INSTANCE = new SecurePinStorage();
|
||||
|
||||
private final Path storageDirectory;
|
||||
private final Path dataFile;
|
||||
private final Path saltFile;
|
||||
private final Path seedFile;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final Map<String, char[]> memoryCache = new ConcurrentHashMap<>();
|
||||
|
||||
public static SecurePinStorage getInstance() {
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
public SecurePinStorage() {
|
||||
this(resolveDefaultDirectory());
|
||||
}
|
||||
|
||||
public SecurePinStorage(Path storageDirectory) {
|
||||
this.storageDirectory = Objects.requireNonNull(storageDirectory);
|
||||
this.dataFile = storageDirectory.resolve(FILE_NAME);
|
||||
this.saltFile = storageDirectory.resolve(SALT_FILE_NAME);
|
||||
this.seedFile = storageDirectory.resolve(SEED_FILE_NAME);
|
||||
this.objectMapper = new ObjectMapper();
|
||||
loadFromDisk();
|
||||
}
|
||||
|
||||
private static Path resolveDefaultDirectory() {
|
||||
String userHome = System.getProperty("user.home", ".");
|
||||
return Path.of(userHome, ".cybercore");
|
||||
}
|
||||
|
||||
/**
|
||||
* Armazena de forma persistente e criptografada o PIN associado ao ID do certificado.
|
||||
*/
|
||||
public synchronized void savePin(String certId, char[] pin) {
|
||||
Objects.requireNonNull(certId, "certId não pode ser nulo");
|
||||
if (pin == null || pin.length == 0) {
|
||||
removePin(certId);
|
||||
return;
|
||||
}
|
||||
|
||||
memoryCache.put(certId, pin.clone());
|
||||
PinSessionManager.getInstance().savePin(certId, pin);
|
||||
persistToDisk();
|
||||
logger.info("PIN do certificado '{}' armazenado com segurança neste computador.", certId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retorna o PIN armazenado para o certificado ou {@code null} se não houver.
|
||||
*/
|
||||
public synchronized char[] getPin(String certId) {
|
||||
if (certId == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 1. Tenta cache em memória
|
||||
char[] cached = memoryCache.get(certId);
|
||||
if (cached != null) {
|
||||
return cached.clone();
|
||||
}
|
||||
|
||||
// 2. Tenta carregar do PinSessionManager
|
||||
char[] sessionPin = PinSessionManager.getInstance().getPin(certId);
|
||||
if (sessionPin != null) {
|
||||
memoryCache.put(certId, sessionPin.clone());
|
||||
return sessionPin.clone();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica se existe um PIN armazenado para o certificado.
|
||||
*/
|
||||
public synchronized boolean hasPin(String certId) {
|
||||
if (certId == null) {
|
||||
return false;
|
||||
}
|
||||
return memoryCache.containsKey(certId) || PinSessionManager.getInstance().hasPin(certId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove o PIN salvo para o certificado e zera a memória.
|
||||
*/
|
||||
public synchronized void removePin(String certId) {
|
||||
if (certId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
char[] old = memoryCache.remove(certId);
|
||||
if (old != null) {
|
||||
Arrays.fill(old, ' ');
|
||||
}
|
||||
PinSessionManager.getInstance().clearPin(certId);
|
||||
persistToDisk();
|
||||
logger.info("PIN do certificado '{}' removido do armazenamento seguro.", certId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove todos os PINs armazenados em disco e zera todas as memórias.
|
||||
*/
|
||||
public synchronized void clearAll() {
|
||||
for (char[] pin : memoryCache.values()) {
|
||||
if (pin != null) {
|
||||
Arrays.fill(pin, ' ');
|
||||
}
|
||||
}
|
||||
memoryCache.clear();
|
||||
PinSessionManager.getInstance().clearAll();
|
||||
|
||||
try {
|
||||
Files.deleteIfExists(dataFile);
|
||||
logger.info("Arquivo de credenciais criptografadas removido com sucesso.");
|
||||
} catch (Exception e) {
|
||||
logger.warn("Falha ao remover arquivo de credenciais: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized int getStoredCount() {
|
||||
return memoryCache.size();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// CRIPTOGRAFIA AES-256-GCM E DERIVAÇÃO DE CHAVE
|
||||
// =========================================================================
|
||||
|
||||
private void persistToDisk() {
|
||||
try {
|
||||
Files.createDirectories(storageDirectory);
|
||||
|
||||
if (memoryCache.isEmpty()) {
|
||||
Files.deleteIfExists(dataFile);
|
||||
return;
|
||||
}
|
||||
|
||||
Map<String, String> plainMap = new HashMap<>();
|
||||
for (Map.Entry<String, char[]> entry : memoryCache.entrySet()) {
|
||||
plainMap.put(entry.getKey(), new String(entry.getValue()));
|
||||
}
|
||||
|
||||
byte[] jsonBytes = objectMapper.writeValueAsBytes(plainMap);
|
||||
byte[] salt = getOrCreateSalt();
|
||||
SecretKey secretKey = deriveKey(salt);
|
||||
|
||||
byte[] iv = new byte[GCM_IV_LENGTH];
|
||||
new SecureRandom().nextBytes(iv);
|
||||
|
||||
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
|
||||
cipher.init(Cipher.ENCRYPT_MODE, secretKey, spec);
|
||||
|
||||
byte[] ciphertext = cipher.doFinal(jsonBytes);
|
||||
|
||||
// Grava: [12 bytes IV] + [ciphertext + tag]
|
||||
try (FileOutputStream fos = new FileOutputStream(dataFile.toFile())) {
|
||||
fos.write(iv);
|
||||
fos.write(ciphertext);
|
||||
}
|
||||
restrictFilePermissions(dataFile);
|
||||
} catch (Exception e) {
|
||||
logger.error("Erro ao persistir PINs criptografados no disco: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private void loadFromDisk() {
|
||||
if (!Files.exists(dataFile) || !Files.exists(saltFile)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
byte[] allBytes = Files.readAllBytes(dataFile);
|
||||
if (allBytes.length <= GCM_IV_LENGTH) {
|
||||
return;
|
||||
}
|
||||
|
||||
byte[] iv = Arrays.copyOfRange(allBytes, 0, GCM_IV_LENGTH);
|
||||
byte[] ciphertext = Arrays.copyOfRange(allBytes, GCM_IV_LENGTH, allBytes.length);
|
||||
|
||||
byte[] salt = Files.readAllBytes(saltFile);
|
||||
SecretKey secretKey = deriveKey(salt);
|
||||
|
||||
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
|
||||
cipher.init(Cipher.DECRYPT_MODE, secretKey, spec);
|
||||
|
||||
byte[] plainBytes = cipher.doFinal(ciphertext);
|
||||
Map<String, String> plainMap = objectMapper.readValue(plainBytes, new TypeReference<>() {});
|
||||
|
||||
memoryCache.clear();
|
||||
for (Map.Entry<String, String> entry : plainMap.entrySet()) {
|
||||
char[] pin = entry.getValue().toCharArray();
|
||||
memoryCache.put(entry.getKey(), pin);
|
||||
PinSessionManager.getInstance().savePin(entry.getKey(), pin);
|
||||
}
|
||||
logger.info("Carregados {} PIN(s) do armazenamento seguro local.", memoryCache.size());
|
||||
} catch (Exception e) {
|
||||
logger.warn("Não foi possível carregar credenciais salvas em disco: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] getOrCreateSalt() throws Exception {
|
||||
if (Files.exists(saltFile)) {
|
||||
return Files.readAllBytes(saltFile);
|
||||
}
|
||||
byte[] salt = new byte[32];
|
||||
new SecureRandom().nextBytes(salt);
|
||||
Files.write(saltFile, salt);
|
||||
restrictFilePermissions(saltFile);
|
||||
return salt;
|
||||
}
|
||||
|
||||
private byte[] getOrCreateSeed() throws Exception {
|
||||
if (Files.exists(seedFile)) {
|
||||
return Files.readAllBytes(seedFile);
|
||||
}
|
||||
byte[] seed = new byte[32];
|
||||
new SecureRandom().nextBytes(seed);
|
||||
Files.write(seedFile, seed);
|
||||
restrictFilePermissions(seedFile);
|
||||
return seed;
|
||||
}
|
||||
|
||||
private void restrictFilePermissions(Path path) {
|
||||
try {
|
||||
PosixFileAttributeView view = Files.getFileAttributeView(path, PosixFileAttributeView.class);
|
||||
if (view != null) {
|
||||
Files.setPosixFilePermissions(path, Set.of(
|
||||
PosixFilePermission.OWNER_READ,
|
||||
PosixFilePermission.OWNER_WRITE
|
||||
));
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// Em sistemas não-POSIX como Windows, as permissões são herdadas da pasta de perfil do usuário (~/.cybercore)
|
||||
}
|
||||
}
|
||||
|
||||
private SecretKey deriveKey(byte[] salt) throws Exception {
|
||||
byte[] seed = getOrCreateSeed();
|
||||
String hostIdentity = String.join(":",
|
||||
System.getProperty("user.name", "unknown"),
|
||||
System.getProperty("user.home", "unknown"),
|
||||
System.getProperty("os.name", "unknown"),
|
||||
System.getProperty("os.arch", "unknown")
|
||||
);
|
||||
byte[] hostBytes = hostIdentity.getBytes(StandardCharsets.UTF_8);
|
||||
byte[] combinedMaterial = new byte[seed.length + hostBytes.length];
|
||||
System.arraycopy(seed, 0, combinedMaterial, 0, seed.length);
|
||||
System.arraycopy(hostBytes, 0, combinedMaterial, seed.length, hostBytes.length);
|
||||
|
||||
char[] materialChars = Base64.getEncoder().encodeToString(combinedMaterial).toCharArray();
|
||||
KeySpec spec = new PBEKeySpec(materialChars, salt, PBKDF2_ITERATIONS, KEY_LENGTH_BITS);
|
||||
SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
|
||||
byte[] keyBytes = factory.generateSecret(spec).getEncoded();
|
||||
|
||||
Arrays.fill(materialChars, ' ');
|
||||
Arrays.fill(combinedMaterial, (byte) 0);
|
||||
|
||||
return new SecretKeySpec(keyBytes, "AES");
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,10 @@ public class SmartCardWatcher {
|
||||
void onCardRemoved(String terminalName);
|
||||
}
|
||||
|
||||
private static final long FAST_POLL_TIMEOUT_MS = 1500L;
|
||||
private static final long MAX_POLL_TIMEOUT_MS = 5000L;
|
||||
private static final int IDLE_CYCLES_BEFORE_BACKOFF = 4;
|
||||
|
||||
private final List<CardEventListener> listeners = new CopyOnWriteArrayList<>();
|
||||
private final Set<String> knownCards = Collections.synchronizedSet(new HashSet<>());
|
||||
|
||||
@@ -109,14 +113,21 @@ public class SmartCardWatcher {
|
||||
return;
|
||||
}
|
||||
|
||||
long currentTimeout = FAST_POLL_TIMEOUT_MS;
|
||||
int consecutiveIdleCycles = 0;
|
||||
|
||||
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);
|
||||
// Aguarda alteração de estado no hardware com timeout adaptativo para reduzir desperdício de CPU
|
||||
Boolean changed = (Boolean) waitForChangeMethod.invoke(cardTerminals, currentTimeout);
|
||||
|
||||
if (Boolean.TRUE.equals(changed) || !running) {
|
||||
if (!running) break;
|
||||
|
||||
// Restabelece polling rápido imediatamente ao registrar atividade no hardware
|
||||
currentTimeout = FAST_POLL_TIMEOUT_MS;
|
||||
consecutiveIdleCycles = 0;
|
||||
|
||||
Set<String> current = getCurrentTerminalsWithCards(cardTerminals, listMethod, cardPresentState);
|
||||
|
||||
// Dispositivos removidos
|
||||
@@ -140,6 +151,12 @@ public class SmartCardWatcher {
|
||||
|
||||
knownCards.clear();
|
||||
knownCards.addAll(current);
|
||||
} else {
|
||||
// Nenhuma alteração no ciclo: aumenta gradualmente o intervalo de espera para economizar CPU
|
||||
consecutiveIdleCycles++;
|
||||
if (consecutiveIdleCycles >= IDLE_CYCLES_BEFORE_BACKOFF) {
|
||||
currentTimeout = Math.min(MAX_POLL_TIMEOUT_MS, currentTimeout + 1000L);
|
||||
}
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
if (e instanceof InterruptedException || (e.getCause() instanceof InterruptedException)) {
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
package br.com.cybercore.signer.core.validation;
|
||||
|
||||
import br.com.cybercore.signer.core.protocol.CcspErrorCode;
|
||||
import br.com.cybercore.signer.core.provider.CertificateProviderException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.cert.CertificateExpiredException;
|
||||
import java.security.cert.CertificateNotYetValidException;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.time.Instant;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Validador rigoroso de cadeias de certificados digitais X.509 ICP-Brasil e padrões internacionais.
|
||||
* Realiza validação temporal, integridade de assinaturas hierárquicas, restrições básicas (CA)
|
||||
* e ancoragem em raízes confiáveis (TrustStore), com mapeamento preciso para erros do protocolo CCSP.
|
||||
*/
|
||||
public final class CertificateChainValidator {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(CertificateChainValidator.class);
|
||||
|
||||
private CertificateChainValidator() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Valida a cadeia de certificados informada em relação à data/hora atual.
|
||||
*/
|
||||
public static void validateChain(List<X509Certificate> chain, Set<X509Certificate> trustedRoots)
|
||||
throws CertificateProviderException {
|
||||
validateChain(chain, trustedRoots, Date.from(Instant.now()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Valida a cadeia de certificados informada em relação à data/hora de referência especificada.
|
||||
*/
|
||||
public static void validateChain(List<X509Certificate> chain, Set<X509Certificate> trustedRoots, Date referenceDate)
|
||||
throws CertificateProviderException {
|
||||
|
||||
if (chain == null || chain.isEmpty()) {
|
||||
throw new CertificateProviderException(
|
||||
CcspErrorCode.CERTIFICATE_NOT_FOUND,
|
||||
"Cadeia de certificados vazia ou não informada."
|
||||
);
|
||||
}
|
||||
|
||||
Date dateToValidate = (referenceDate != null) ? referenceDate : Date.from(Instant.now());
|
||||
|
||||
// 1. Validação temporal de cada elo da cadeia
|
||||
for (X509Certificate cert : chain) {
|
||||
try {
|
||||
cert.checkValidity(dateToValidate);
|
||||
} catch (CertificateExpiredException e) {
|
||||
logger.warn("Certificado expirado na cadeia: {}", cert.getSubjectX500Principal());
|
||||
throw new CertificateProviderException(
|
||||
CcspErrorCode.CERTIFICATE_EXPIRED,
|
||||
"Certificado '" + cert.getSubjectX500Principal().getName() + "' está expirado em " + dateToValidate,
|
||||
e
|
||||
);
|
||||
} catch (CertificateNotYetValidException e) {
|
||||
logger.warn("Certificado ainda não válido na cadeia: {}", cert.getSubjectX500Principal());
|
||||
throw new CertificateProviderException(
|
||||
CcspErrorCode.CERTIFICATE_INVALID,
|
||||
"Certificado '" + cert.getSubjectX500Principal().getName() + "' ainda não é válido em " + dateToValidate,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Validação hierárquica de assinaturas e restrições básicas de CA
|
||||
for (int i = 0; i < chain.size() - 1; i++) {
|
||||
X509Certificate subjectCert = chain.get(i);
|
||||
X509Certificate issuerCert = chain.get(i + 1);
|
||||
|
||||
// Verifica se o emissor do subject é de fato o issuer
|
||||
if (!subjectCert.getIssuerX500Principal().equals(issuerCert.getSubjectX500Principal())) {
|
||||
throw new CertificateProviderException(
|
||||
CcspErrorCode.CERTIFICATE_INVALID,
|
||||
"Quebra na cadeia de confiança: emissor de '" + subjectCert.getSubjectX500Principal().getName()
|
||||
+ "' não corresponde ao sujeito de '" + issuerCert.getSubjectX500Principal().getName() + "'."
|
||||
);
|
||||
}
|
||||
|
||||
// O emissor deve ser uma autoridade certificadora (BasicConstraints isCA == true)
|
||||
if (issuerCert.getBasicConstraints() < 0) {
|
||||
throw new CertificateProviderException(
|
||||
CcspErrorCode.CERTIFICATE_INVALID,
|
||||
"O certificado intermediário '" + issuerCert.getSubjectX500Principal().getName()
|
||||
+ "' não possui extensão BasicConstraints autorizando emissão de certificados (isCA=false)."
|
||||
);
|
||||
}
|
||||
|
||||
// Verifica a assinatura criptográfica de subjectCert pela chave pública de issuerCert
|
||||
try {
|
||||
subjectCert.verify(issuerCert.getPublicKey());
|
||||
} catch (GeneralSecurityException e) {
|
||||
logger.error("Assinatura inválida no certificado {}: {}", subjectCert.getSubjectX500Principal(), e.getMessage());
|
||||
throw new CertificateProviderException(
|
||||
CcspErrorCode.CERTIFICATE_INVALID,
|
||||
"A assinatura do certificado '" + subjectCert.getSubjectX500Principal().getName()
|
||||
+ "' não pôde ser verificada pela chave pública de seu emissor: " + e.getMessage(),
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Validação da raiz (Root CA)
|
||||
X509Certificate rootCert = chain.get(chain.size() - 1);
|
||||
boolean isSelfSigned = rootCert.getIssuerX500Principal().equals(rootCert.getSubjectX500Principal());
|
||||
|
||||
if (isSelfSigned) {
|
||||
try {
|
||||
rootCert.verify(rootCert.getPublicKey());
|
||||
} catch (GeneralSecurityException e) {
|
||||
throw new CertificateProviderException(
|
||||
CcspErrorCode.CERTIFICATE_INVALID,
|
||||
"Assinatura do certificado raiz autoassinado é inválida.",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Validação contra TrustStore de raízes confiáveis se fornecido
|
||||
if (trustedRoots != null && !trustedRoots.isEmpty()) {
|
||||
boolean anchoredInTrustStore = false;
|
||||
for (X509Certificate trusted : trustedRoots) {
|
||||
if (trusted.equals(rootCert) || (isSelfSigned && trusted.getPublicKey().equals(rootCert.getPublicKey()))) {
|
||||
anchoredInTrustStore = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!anchoredInTrustStore) {
|
||||
throw new CertificateProviderException(
|
||||
CcspErrorCode.CERTIFICATE_INVALID,
|
||||
"A Autoridade Certificadora Raiz '" + rootCert.getSubjectX500Principal().getName()
|
||||
+ "' não está presente na lista de raízes confiáveis do sistema."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import org.bouncycastle.util.Store;
|
||||
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.crypto.dsig.XMLSignature;
|
||||
@@ -221,32 +222,59 @@ public final class SignatureValidationService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Validação de assinaturas embutidas em documentos XML (XMLDSig).
|
||||
* Validação de assinaturas embutidas em documentos XML (XAdES ou XMLDSig).
|
||||
*/
|
||||
private ValidationResult validateXml(byte[] xmlBytes, String fileName) {
|
||||
try {
|
||||
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);
|
||||
Document doc = dbf.newDocumentBuilder().parse(new ByteArrayInputStream(xmlBytes));
|
||||
|
||||
// Registro de atributos Id no DOM
|
||||
registerAllIdAttributes(doc.getDocumentElement());
|
||||
|
||||
NodeList nl = doc.getElementsByTagNameNS(XMLSignature.XMLNS, "Signature");
|
||||
if (nl.getLength() == 0) {
|
||||
nl = doc.getElementsByTagName("Signature");
|
||||
}
|
||||
if (nl.getLength() == 0) {
|
||||
return new ValidationResult(
|
||||
ValidationStatus.INVALID,
|
||||
ValidationType.XML_DSIG,
|
||||
"Nenhum elemento <Signature> XMLDSig encontrado no documento.",
|
||||
"Nenhum elemento <Signature> XML encontrado no documento.",
|
||||
List.of(),
|
||||
null,
|
||||
fileName
|
||||
);
|
||||
}
|
||||
|
||||
// Identificação de padrão XAdES
|
||||
NodeList qpList = doc.getElementsByTagNameNS("http://uri.etsi.org/01903/v1.3.2#", "QualifyingProperties");
|
||||
if (qpList.getLength() == 0) {
|
||||
qpList = doc.getElementsByTagName("QualifyingProperties");
|
||||
}
|
||||
boolean isXades = qpList.getLength() > 0;
|
||||
ValidationType valType = isXades ? ValidationType.XADES_XML : ValidationType.XML_DSIG;
|
||||
|
||||
XMLSignatureFactory fac = XMLSignatureFactory.getInstance("DOM");
|
||||
List<ValidationSignerInfo> signers = new ArrayList<>();
|
||||
boolean allValid = true;
|
||||
|
||||
for (int i = 0; i < nl.getLength(); i++) {
|
||||
DOMValidateContext valContext = new DOMValidateContext(new KeyValueKeySelector(), nl.item(i));
|
||||
Element sigElement = (Element) nl.item(i);
|
||||
DOMValidateContext valContext = new DOMValidateContext(new KeyValueKeySelector(), sigElement);
|
||||
|
||||
// Registra nós com atributo Id no valContext
|
||||
NodeList allNodes = doc.getElementsByTagName("*");
|
||||
for (int n = 0; n < allNodes.getLength(); n++) {
|
||||
if (allNodes.item(n) instanceof Element e && e.hasAttribute("Id")) {
|
||||
valContext.setIdAttributeNS(e, null, "Id");
|
||||
}
|
||||
}
|
||||
|
||||
XMLSignature signature = fac.unmarshalXMLSignature(valContext);
|
||||
boolean coreValidity = signature.validate(valContext);
|
||||
|
||||
@@ -254,22 +282,66 @@ public final class SignatureValidationService {
|
||||
allValid = false;
|
||||
}
|
||||
|
||||
// Extração de metadados do signatário a partir do KeyInfo
|
||||
String signerName = "Signatário XML #" + (i + 1);
|
||||
String identifier = "";
|
||||
String issuer = isXades ? "ICP-Brasil / XAdES" : "Assinatura Enveloped XML";
|
||||
String certSubject = "XML Signature Element";
|
||||
|
||||
if (signature.getKeyInfo() != null) {
|
||||
for (Object kiContent : signature.getKeyInfo().getContent()) {
|
||||
if (kiContent instanceof javax.xml.crypto.dsig.keyinfo.X509Data x509Data) {
|
||||
for (Object o : x509Data.getContent()) {
|
||||
if (o instanceof X509Certificate cert) {
|
||||
certSubject = cert.getSubjectX500Principal().getName();
|
||||
signerName = X509CertificateUtils.extractCommonName(certSubject);
|
||||
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;
|
||||
}
|
||||
}
|
||||
issuer = X509CertificateUtils.extractCommonName(cert.getIssuerX500Principal().getName());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Data de assinatura (SigningTime se XAdES, senão data atual)
|
||||
String dateStr = new SimpleDateFormat("dd/MM/yyyy HH:mm:ss").format(new Date());
|
||||
if (isXades) {
|
||||
NodeList stList = doc.getElementsByTagNameNS("http://uri.etsi.org/01903/v1.3.2#", "SigningTime");
|
||||
if (stList.getLength() > 0) {
|
||||
dateStr = stList.item(0).getTextContent();
|
||||
}
|
||||
}
|
||||
|
||||
signers.add(new ValidationSignerInfo(
|
||||
"Signatário XML #" + (i + 1),
|
||||
"",
|
||||
"Assinatura Enveloped XML",
|
||||
new SimpleDateFormat("dd/MM/yyyy HH:mm:ss").format(new Date()),
|
||||
signerName,
|
||||
identifier,
|
||||
issuer,
|
||||
dateStr,
|
||||
coreValidity,
|
||||
coreValidity,
|
||||
signature.getSignedInfo().getSignatureMethod().getAlgorithm(),
|
||||
"XMLDSig Element",
|
||||
"Validade estrutural e criptográfica: " + (coreValidity ? "OK" : "FALHA")
|
||||
certSubject,
|
||||
(isXades ? "Padrão XAdES-BES (ETSI TS 101 903 / ICP-Brasil)" : "Padrão XMLDSig W3C")
|
||||
+ " • " + (coreValidity ? "Assinatura íntegra" : "Integridade violada")
|
||||
));
|
||||
}
|
||||
|
||||
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);
|
||||
String msg = allValid
|
||||
? (isXades
|
||||
? "Documento XML assinado e íntegro no padrão avançado XAdES-BES (ICP-Brasil)."
|
||||
: "Documento XML assinado e válido conforme padrão W3C XMLDSig.")
|
||||
: "Assinatura XML inválida ou com integridade violada.";
|
||||
return new ValidationResult(status, valType, msg, signers, null, fileName);
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("Erro ao validar XML: {}", e.getMessage(), e);
|
||||
@@ -284,6 +356,23 @@ public final class SignatureValidationService {
|
||||
}
|
||||
}
|
||||
|
||||
private static void registerAllIdAttributes(Element root) {
|
||||
if (root == null) return;
|
||||
if (root.hasAttribute("Id")) {
|
||||
root.setIdAttribute("Id", true);
|
||||
} else if (root.hasAttribute("id")) {
|
||||
root.setIdAttribute("id", true);
|
||||
} else if (root.hasAttribute("ID")) {
|
||||
root.setIdAttribute("ID", true);
|
||||
}
|
||||
NodeList children = root.getChildNodes();
|
||||
for (int i = 0; i < children.getLength(); i++) {
|
||||
if (children.item(i) instanceof Element child) {
|
||||
registerAllIdAttributes(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processa e valida criptograficamente os signatários de um pacote CMSSignedData.
|
||||
*/
|
||||
|
||||
@@ -7,6 +7,7 @@ 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),
|
||||
XADES_XML("Assinatura Incorporada em XML (XAdES / ICP-Brasil)", true),
|
||||
XML_DSIG("Assinatura Incorporada em XML (XML-DSig)", true),
|
||||
UNKNOWN("Formato Desconhecido", false);
|
||||
|
||||
|
||||
@@ -3,30 +3,43 @@ package br.com.cybercore.signer.core.xml;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Parâmetros de configuração para a operação de assinatura digital XMLDSig.
|
||||
* Parâmetros de configuração para a operação de assinatura digital XML (XAdES ou XMLDSig).
|
||||
*/
|
||||
public record XmlSignatureOptions(
|
||||
String certificateId,
|
||||
char[] pinOrPassword,
|
||||
String targetElementTag,
|
||||
String targetElementId,
|
||||
String signatureAlgorithm
|
||||
String signatureAlgorithm,
|
||||
XmlSignatureProfile profile
|
||||
) {
|
||||
|
||||
public static final String DEFAULT_ALGORITHM = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256";
|
||||
public static final XmlSignatureProfile DEFAULT_PROFILE = XmlSignatureProfile.XADES_BES;
|
||||
|
||||
public XmlSignatureOptions {
|
||||
Objects.requireNonNull(certificateId, "ID do certificado não pode ser nulo.");
|
||||
if (signatureAlgorithm == null || signatureAlgorithm.isBlank()) {
|
||||
signatureAlgorithm = DEFAULT_ALGORITHM;
|
||||
}
|
||||
if (profile == null) {
|
||||
profile = DEFAULT_PROFILE;
|
||||
}
|
||||
}
|
||||
|
||||
public static XmlSignatureOptions wholeDocument(String certificateId, char[] pinOrPassword) {
|
||||
return new XmlSignatureOptions(certificateId, pinOrPassword, null, null, DEFAULT_ALGORITHM);
|
||||
return new XmlSignatureOptions(certificateId, pinOrPassword, null, null, DEFAULT_ALGORITHM, DEFAULT_PROFILE);
|
||||
}
|
||||
|
||||
public static XmlSignatureOptions wholeDocument(String certificateId, char[] pinOrPassword, XmlSignatureProfile profile) {
|
||||
return new XmlSignatureOptions(certificateId, pinOrPassword, null, null, DEFAULT_ALGORITHM, profile);
|
||||
}
|
||||
|
||||
public static XmlSignatureOptions forElement(String certificateId, char[] pinOrPassword, String tag, String id) {
|
||||
return new XmlSignatureOptions(certificateId, pinOrPassword, tag, id, DEFAULT_ALGORITHM);
|
||||
return new XmlSignatureOptions(certificateId, pinOrPassword, tag, id, DEFAULT_ALGORITHM, DEFAULT_PROFILE);
|
||||
}
|
||||
|
||||
public static XmlSignatureOptions forElement(String certificateId, char[] pinOrPassword, String tag, String id, XmlSignatureProfile profile) {
|
||||
return new XmlSignatureOptions(certificateId, pinOrPassword, tag, id, DEFAULT_ALGORITHM, profile);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package br.com.cybercore.signer.core.xml;
|
||||
|
||||
/**
|
||||
* Perfis de assinatura digital para documentos XML suportados pelo CyberCore Signer.
|
||||
*/
|
||||
public enum XmlSignatureProfile {
|
||||
|
||||
/**
|
||||
* Padrão XAdES-BES (XML Advanced Electronic Signatures - Basic Electronic Signature).
|
||||
* Em conformidade com a especificação ETSI TS 101 903 e padrão ICP-Brasil DOC-ICP-15.03 (AD-RB).
|
||||
* Inclui propriedades qualificadas assinadas: data/hora de assinatura (SigningTime)
|
||||
* e referência criptográfica do certificado do signatário (SigningCertificate).
|
||||
*/
|
||||
XADES_BES("XAdES-BES (Padrão ICP-Brasil / ETSI)", "Assinatura avançada com data/hora e certificado qualificado vinculados"),
|
||||
|
||||
/**
|
||||
* Padrão W3C XMLDSig convencional (Enveloped Signature sem propriedades qualificadas XAdES).
|
||||
* Mantido para compatibilidade com layouts fiscais estritos (ex: NF-e, CT-e, MDF-e SEFAZ legados).
|
||||
*/
|
||||
XMLDSIG("XMLDSig Clássico (W3C)", "Assinatura enveloped básica sem propriedades qualificadas");
|
||||
|
||||
private final String displayName;
|
||||
private final String description;
|
||||
|
||||
XmlSignatureProfile(String displayName, String description) {
|
||||
this.displayName = displayName;
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public String getDisplayName() {
|
||||
return displayName;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
}
|
||||
@@ -17,12 +17,14 @@ import javax.xml.crypto.KeySelector;
|
||||
import javax.xml.crypto.KeySelectorException;
|
||||
import javax.xml.crypto.KeySelectorResult;
|
||||
import javax.xml.crypto.XMLCryptoContext;
|
||||
import javax.xml.crypto.XMLStructure;
|
||||
import javax.xml.crypto.dom.DOMStructure;
|
||||
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.XMLObject;
|
||||
import javax.xml.crypto.dsig.XMLSignature;
|
||||
import javax.xml.crypto.dsig.XMLSignatureFactory;
|
||||
import javax.xml.crypto.dsig.dom.DOMSignContext;
|
||||
@@ -43,25 +45,41 @@ import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.security.Key;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.PublicKey;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 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.).
|
||||
* Serviço de alto nível para Assinatura Digital de Documentos XML segundo os padrões:
|
||||
* - XAdES-BES (ETSI TS 101 903 / ICP-Brasil DOC-ICP-15.03 - Padrão Avançado)
|
||||
* - W3C XMLDSig convencional (Enveloped / NF-e / SPED legado)
|
||||
*
|
||||
* 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);
|
||||
|
||||
public static final String XADES_132_NS = "http://uri.etsi.org/01903/v1.3.2#";
|
||||
public static final String XADES_SIGNED_PROPERTIES_TYPE = "http://uri.etsi.org/01903#SignedProperties";
|
||||
public static final String XMLDSIG_NS = XMLSignature.XMLNS;
|
||||
|
||||
private static final DateTimeFormatter ISO_INSTANT_FORMATTER =
|
||||
DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ss'Z'").withZone(ZoneOffset.UTC);
|
||||
|
||||
private final CertificateManager certificateManager;
|
||||
|
||||
public XmlSigningService(CertificateManager certificateManager) {
|
||||
@@ -85,7 +103,7 @@ public class XmlSigningService {
|
||||
try (FileOutputStream fos = new FileOutputStream(targetXml)) {
|
||||
fos.write(signedBytes);
|
||||
}
|
||||
logger.info("XML assinado com sucesso e salvo em: {}", targetXml.getAbsolutePath());
|
||||
logger.info("XML assinado com sucesso ({}) e salvo em: {}", options.profile().getDisplayName(), targetXml.getAbsolutePath());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -117,7 +135,8 @@ public class XmlSigningService {
|
||||
}
|
||||
SigningKeyMaterial keyMaterial = keyMaterialOpt.get();
|
||||
|
||||
logger.info("Iniciando assinatura XMLDSig com certificado '{}' [tipo={}]", cert.commonName(), cert.type());
|
||||
logger.info("Iniciando assinatura XML [perfil={}] com certificado '{}' [tipo={}]",
|
||||
options.profile(), cert.commonName(), cert.type());
|
||||
|
||||
// 2. Parse Seguro do XML (Prevenção rigorosa de XXE)
|
||||
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
|
||||
@@ -134,13 +153,13 @@ public class XmlSigningService {
|
||||
// 3. Configuração do XMLSignatureFactory
|
||||
XMLSignatureFactory fac = XMLSignatureFactory.getInstance("DOM");
|
||||
|
||||
// Transformações padrão: Enveloped + C14N
|
||||
List<Transform> transforms = new ArrayList<>();
|
||||
transforms.add(fac.newTransform(Transform.ENVELOPED, (TransformParameterSpec) null));
|
||||
transforms.add(fac.newTransform(CanonicalizationMethod.INCLUSIVE, (TransformParameterSpec) null));
|
||||
// Transformações padrão para os dados de negócio: Enveloped + C14N
|
||||
List<Transform> dataTransforms = new ArrayList<>();
|
||||
dataTransforms.add(fac.newTransform(Transform.ENVELOPED, (TransformParameterSpec) null));
|
||||
dataTransforms.add(fac.newTransform(CanonicalizationMethod.INCLUSIVE, (TransformParameterSpec) null));
|
||||
|
||||
// Determinação da Referência e Ponto de Inserção
|
||||
Reference ref;
|
||||
// Determinação da Referência de Dados e Ponto de Inserção da Assinatura
|
||||
Reference dataRef;
|
||||
Node insertionParent;
|
||||
|
||||
if (options.targetElementId() != null && !options.targetElementId().isBlank()) {
|
||||
@@ -150,7 +169,7 @@ public class XmlSigningService {
|
||||
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);
|
||||
dataRef = fac.newReference("#" + elemId, fac.newDigestMethod(DigestMethod.SHA256, null), dataTransforms, null, null);
|
||||
insertionParent = (targetElement.getParentNode() != null) ? targetElement.getParentNode() : doc.getDocumentElement();
|
||||
} else if (options.targetElementTag() != null && !options.targetElementTag().isBlank()) {
|
||||
NodeList list = doc.getElementsByTagName(options.targetElementTag());
|
||||
@@ -160,42 +179,87 @@ public class XmlSigningService {
|
||||
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);
|
||||
dataRef = fac.newReference("#" + idVal, fac.newDigestMethod(DigestMethod.SHA256, null), dataTransforms, null, null);
|
||||
insertionParent = (elem.getParentNode() != null) ? elem.getParentNode() : doc.getDocumentElement();
|
||||
} else {
|
||||
ref = fac.newReference("", fac.newDigestMethod(DigestMethod.SHA256, null), transforms, null, null);
|
||||
dataRef = fac.newReference("", fac.newDigestMethod(DigestMethod.SHA256, null), dataTransforms, null, null);
|
||||
insertionParent = doc.getDocumentElement();
|
||||
}
|
||||
} else {
|
||||
// Assinatura Enveloped do documento completo
|
||||
ref = fac.newReference("", fac.newDigestMethod(DigestMethod.SHA256, null), transforms, null, null);
|
||||
dataRef = fac.newReference("", fac.newDigestMethod(DigestMethod.SHA256, null), dataTransforms, null, null);
|
||||
insertionParent = doc.getDocumentElement();
|
||||
}
|
||||
|
||||
// 4. Montagem do SignedInfo
|
||||
List<Reference> references = new ArrayList<>();
|
||||
references.add(dataRef);
|
||||
|
||||
List<XMLObject> xmlObjects = new ArrayList<>();
|
||||
String signatureUniqueId = "sig-" + UUID.randomUUID();
|
||||
Element signedPropertiesElem = null;
|
||||
|
||||
// 4. Se for perfil XAdES-BES, constrói as QualifyingProperties e a Reference correspondente
|
||||
if (options.profile() == XmlSignatureProfile.XADES_BES) {
|
||||
String signedPropsId = "signed-props-" + UUID.randomUUID();
|
||||
signedPropertiesElem = buildXadesSignedProperties(doc, signedPropsId, keyMaterial.signingCertificate(), Instant.now());
|
||||
|
||||
Element qualifyingProperties = doc.createElementNS(XADES_132_NS, "xades:QualifyingProperties");
|
||||
qualifyingProperties.setAttribute("Target", "#" + signatureUniqueId);
|
||||
qualifyingProperties.appendChild(signedPropertiesElem);
|
||||
|
||||
XMLStructure xadesStructure = new DOMStructure(qualifyingProperties);
|
||||
XMLObject xadesObject = fac.newXMLObject(Collections.singletonList(xadesStructure), null, null, null);
|
||||
xmlObjects.add(xadesObject);
|
||||
|
||||
// Adiciona Referência Criptográfica cobrindo as SignedProperties
|
||||
List<Transform> xadesTransforms = Collections.singletonList(
|
||||
fac.newTransform(CanonicalizationMethod.INCLUSIVE, (TransformParameterSpec) null)
|
||||
);
|
||||
Reference xadesRef = fac.newReference(
|
||||
"#" + signedPropsId,
|
||||
fac.newDigestMethod(DigestMethod.SHA256, null),
|
||||
xadesTransforms,
|
||||
XADES_SIGNED_PROPERTIES_TYPE,
|
||||
null
|
||||
);
|
||||
references.add(xadesRef);
|
||||
}
|
||||
|
||||
// 5. Montagem do SignedInfo
|
||||
SignedInfo si = fac.newSignedInfo(
|
||||
fac.newCanonicalizationMethod(CanonicalizationMethod.INCLUSIVE, (C14NMethodParameterSpec) null),
|
||||
fac.newSignatureMethod(options.signatureAlgorithm(), null),
|
||||
Collections.singletonList(ref)
|
||||
references
|
||||
);
|
||||
|
||||
// 5. Inclusão da Cadeia de Certificados no KeyInfo
|
||||
// 6. Inclusão da Cadeia de Certificados no KeyInfo
|
||||
KeyInfoFactory kif = fac.getKeyInfoFactory();
|
||||
List<Object> 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
|
||||
// 7. 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);
|
||||
if (signedPropertiesElem != null) {
|
||||
dsc.setIdAttributeNS(signedPropertiesElem, null, "Id");
|
||||
}
|
||||
|
||||
XMLSignature signature = fac.newXMLSignature(
|
||||
si,
|
||||
ki,
|
||||
xmlObjects,
|
||||
signatureUniqueId,
|
||||
null
|
||||
);
|
||||
|
||||
signature.sign(dsc);
|
||||
|
||||
// 7. Serialização do XML Assinado
|
||||
// 8. Serialização do XML Assinado
|
||||
TransformerFactory tf = TransformerFactory.newInstance();
|
||||
Transformer trans = tf.newTransformer();
|
||||
trans.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "no");
|
||||
@@ -205,12 +269,13 @@ public class XmlSigningService {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
trans.transform(new DOMSource(doc), new StreamResult(baos));
|
||||
|
||||
logger.info("Assinatura XMLDSig gerada com sucesso. Tamanho: {} bytes", baos.size());
|
||||
logger.info("Assinatura XML ({}) gerada com sucesso. Tamanho final: {} bytes",
|
||||
options.profile(), baos.size());
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Valida a integridade criptográfica de um documento XML assinado com XMLDSig.
|
||||
* Valida a integridade criptográfica de um documento XML assinado com XAdES ou XMLDSig.
|
||||
*/
|
||||
public boolean validateXmlSignature(byte[] signedXmlBytes) throws Exception {
|
||||
Objects.requireNonNull(signedXmlBytes, "Bytes do XML assinado não podem ser nulos.");
|
||||
@@ -218,24 +283,17 @@ public class XmlSigningService {
|
||||
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);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Registra nós com atributo Id/id/ID para que o ResourceResolver localize as referências
|
||||
registerIdAttributes(doc.getDocumentElement());
|
||||
|
||||
NodeList nl = doc.getElementsByTagNameNS(XMLSignature.XMLNS, "Signature");
|
||||
NodeList nl = doc.getElementsByTagNameNS(XMLDSIG_NS, "Signature");
|
||||
if (nl.getLength() == 0) {
|
||||
nl = doc.getElementsByTagName("Signature");
|
||||
}
|
||||
@@ -248,8 +306,168 @@ public class XmlSigningService {
|
||||
XMLSignatureFactory fac = XMLSignatureFactory.getInstance("DOM");
|
||||
DOMValidateContext valContext = new DOMValidateContext(new X509KeySelector(), sigElement);
|
||||
|
||||
// Registra explicitamente elementos com atributo Id no valContext
|
||||
NodeList allNodes = doc.getElementsByTagName("*");
|
||||
for (int i = 0; i < allNodes.getLength(); i++) {
|
||||
if (allNodes.item(i) instanceof Element e) {
|
||||
if (e.hasAttribute("Id")) {
|
||||
valContext.setIdAttributeNS(e, null, "Id");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
XMLSignature signature = fac.unmarshalXMLSignature(valContext);
|
||||
return signature.validate(valContext);
|
||||
boolean coreValidity = signature.validate(valContext);
|
||||
if (!coreValidity) {
|
||||
boolean sv = signature.getSignatureValue().validate(valContext);
|
||||
logger.warn("Validação XML falhou. SignatureValue válido: {}", sv);
|
||||
int refIdx = 0;
|
||||
for (Object o : signature.getSignedInfo().getReferences()) {
|
||||
if (o instanceof Reference r) {
|
||||
boolean rValid = r.validate(valContext);
|
||||
byte[] calcDigest = r.getCalculatedDigestValue();
|
||||
byte[] storedDigest = r.getDigestValue();
|
||||
String calcB64 = calcDigest != null ? Base64.getEncoder().encodeToString(calcDigest) : "null";
|
||||
String storedB64 = storedDigest != null ? Base64.getEncoder().encodeToString(storedDigest) : "null";
|
||||
logger.warn("Reference[{}] URI='{}' válida: {} | calcDigest={} | storedDigest={}",
|
||||
refIdx++, r.getURI(), rValid, calcB64, storedB64);
|
||||
}
|
||||
}
|
||||
}
|
||||
return coreValidity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspeciona e extrai informações detalhadas da assinatura XML (XAdES ou XMLDSig).
|
||||
*/
|
||||
public XmlSignatureDetails inspectXmlSignature(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));
|
||||
registerIdAttributes(doc.getDocumentElement());
|
||||
|
||||
NodeList nl = doc.getElementsByTagNameNS(XMLDSIG_NS, "Signature");
|
||||
if (nl.getLength() == 0) {
|
||||
nl = doc.getElementsByTagName("Signature");
|
||||
}
|
||||
if (nl.getLength() == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Element sigElem = (Element) nl.item(0);
|
||||
String sigId = sigElem.getAttribute("Id");
|
||||
|
||||
// Verifica presença de XAdES
|
||||
NodeList qpNodes = doc.getElementsByTagNameNS(XADES_132_NS, "QualifyingProperties");
|
||||
boolean isXades = qpNodes.getLength() > 0;
|
||||
|
||||
String signingTime = null;
|
||||
if (isXades) {
|
||||
NodeList timeNodes = doc.getElementsByTagNameNS(XADES_132_NS, "SigningTime");
|
||||
if (timeNodes.getLength() > 0) {
|
||||
signingTime = timeNodes.item(0).getTextContent();
|
||||
}
|
||||
}
|
||||
|
||||
// Validação criptográfica
|
||||
boolean isValid = false;
|
||||
try {
|
||||
isValid = validateXmlSignature(signedXmlBytes);
|
||||
} catch (Exception ex) {
|
||||
logger.warn("Validação da assinatura XML falhou durante inspeção: {}", ex.getMessage());
|
||||
}
|
||||
|
||||
String sigMethod = "";
|
||||
NodeList smNodes = sigElem.getElementsByTagNameNS(XMLDSIG_NS, "SignatureMethod");
|
||||
if (smNodes.getLength() > 0 && smNodes.item(0) instanceof Element smElem) {
|
||||
sigMethod = smElem.getAttribute("Algorithm");
|
||||
}
|
||||
|
||||
return new XmlSignatureDetails(
|
||||
isValid,
|
||||
isXades ? XmlSignatureProfile.XADES_BES : XmlSignatureProfile.XMLDSIG,
|
||||
sigMethod,
|
||||
signingTime,
|
||||
sigId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constrói a estrutura DOM das propriedades assinadas do XAdES (SignedProperties).
|
||||
*/
|
||||
private Element buildXadesSignedProperties(Document doc, String signedPropsId, X509Certificate cert, Instant signInstant) throws Exception {
|
||||
Element signedProperties = doc.createElementNS(XADES_132_NS, "xades:SignedProperties");
|
||||
signedProperties.setAttributeNS("http://www.w3.org/2000/xmlns/", "xmlns:xades", XADES_132_NS);
|
||||
signedProperties.setAttributeNS("http://www.w3.org/2000/xmlns/", "xmlns:ds", XMLDSIG_NS);
|
||||
signedProperties.setAttribute("Id", signedPropsId);
|
||||
signedProperties.setIdAttribute("Id", true);
|
||||
|
||||
Element signedSignatureProperties = doc.createElementNS(XADES_132_NS, "xades:SignedSignatureProperties");
|
||||
|
||||
// 1. Data e Hora UTC da Assinatura
|
||||
Element signingTime = doc.createElementNS(XADES_132_NS, "xades:SigningTime");
|
||||
signingTime.setTextContent(ISO_INSTANT_FORMATTER.format(signInstant));
|
||||
signedSignatureProperties.appendChild(signingTime);
|
||||
|
||||
// 2. Vínculo Criptográfico do Certificado do Signatário (SigningCertificate)
|
||||
Element signingCertificate = doc.createElementNS(XADES_132_NS, "xades:SigningCertificate");
|
||||
Element certElem = doc.createElementNS(XADES_132_NS, "xades:Cert");
|
||||
|
||||
// Digest SHA-256 do Certificado DER
|
||||
Element certDigest = doc.createElementNS(XADES_132_NS, "xades:CertDigest");
|
||||
Element digestMethod = doc.createElementNS(XMLDSIG_NS, "ds:DigestMethod");
|
||||
digestMethod.setAttribute("Algorithm", "http://www.w3.org/2001/04/xmlenc#sha256");
|
||||
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||
byte[] certHash = md.digest(cert.getEncoded());
|
||||
String certHashB64 = Base64.getEncoder().encodeToString(certHash);
|
||||
|
||||
Element digestValue = doc.createElementNS(XMLDSIG_NS, "ds:DigestValue");
|
||||
digestValue.setTextContent(certHashB64);
|
||||
|
||||
certDigest.appendChild(digestMethod);
|
||||
certDigest.appendChild(digestValue);
|
||||
certElem.appendChild(certDigest);
|
||||
|
||||
// Dados do Emissor e Serial do Certificado
|
||||
Element issuerSerial = doc.createElementNS(XADES_132_NS, "xades:IssuerSerial");
|
||||
Element x509IssuerName = doc.createElementNS(XMLDSIG_NS, "ds:X509IssuerName");
|
||||
x509IssuerName.setTextContent(cert.getIssuerX500Principal().getName());
|
||||
|
||||
Element x509SerialNumber = doc.createElementNS(XMLDSIG_NS, "ds:X509SerialNumber");
|
||||
x509SerialNumber.setTextContent(cert.getSerialNumber().toString());
|
||||
|
||||
issuerSerial.appendChild(x509IssuerName);
|
||||
issuerSerial.appendChild(x509SerialNumber);
|
||||
certElem.appendChild(issuerSerial);
|
||||
|
||||
signingCertificate.appendChild(certElem);
|
||||
signedSignatureProperties.appendChild(signingCertificate);
|
||||
|
||||
signedProperties.appendChild(signedSignatureProperties);
|
||||
return signedProperties;
|
||||
}
|
||||
|
||||
private static void registerIdAttributes(Element root) {
|
||||
if (root == null) return;
|
||||
if (root.hasAttribute("Id")) {
|
||||
root.setIdAttribute("Id", true);
|
||||
} else if (root.hasAttribute("id")) {
|
||||
root.setIdAttribute("id", true);
|
||||
} else if (root.hasAttribute("ID")) {
|
||||
root.setIdAttribute("ID", true);
|
||||
}
|
||||
|
||||
NodeList children = root.getChildNodes();
|
||||
for (int i = 0; i < children.getLength(); i++) {
|
||||
if (children.item(i) instanceof Element child) {
|
||||
registerIdAttributes(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Element findElementById(Document doc, String id, String hintTag) {
|
||||
@@ -264,6 +482,17 @@ public class XmlSigningService {
|
||||
return doc.getElementById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO de detalhes de uma assinatura XML.
|
||||
*/
|
||||
public record XmlSignatureDetails(
|
||||
boolean valid,
|
||||
XmlSignatureProfile profile,
|
||||
String algorithm,
|
||||
String signingTime,
|
||||
String signatureId
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Seletor de chave pública baseado no certificado X.509 embutido no KeyInfo.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 54" width="36" height="54">
|
||||
<!-- Escudo Criptográfico Vetorial ICP-Brasil -->
|
||||
<path d="M18 4 L32 9 V24 C32 37 25 46 18 50 C11 46 4 37 4 24 V9 Z" fill="#0d47a1" opacity="0.14"/>
|
||||
<path d="M18 4 L32 9 V24 C32 37 25 46 18 50 C11 46 4 37 4 24 V9 Z" fill="none" stroke="#0d47a1" stroke-width="2.2" stroke-linejoin="round"/>
|
||||
|
||||
<!-- Corpo do Cadeado de Segurança -->
|
||||
<rect x="11" y="24" width="14" height="12" rx="2.5" fill="#0284c7"/>
|
||||
|
||||
<!-- Arco Superior do Cadeado -->
|
||||
<path d="M13.5 24 V19 C13.5 16.5 15.5 14.5 18 14.5 C20.5 14.5 22.5 16.5 22.5 19 V24" fill="none" stroke="#0284c7" stroke-width="2.2" stroke-linecap="round"/>
|
||||
|
||||
<!-- Orifício da Chave -->
|
||||
<circle cx="18" cy="29" r="1.6" fill="#ffffff"/>
|
||||
<path d="M17.3 29.5 L18.7 29.5 L19.2 33 L16.8 33 Z" fill="#ffffff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 858 B |
@@ -0,0 +1,325 @@
|
||||
package br.com.cybercore.signer.core.compliance;
|
||||
|
||||
import br.com.cybercore.signer.core.domain.ManagedCertificate;
|
||||
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.PdfSealThemeRegistry;
|
||||
import br.com.cybercore.signer.core.provider.CertificateManager;
|
||||
import br.com.cybercore.signer.core.provider.Pkcs12CertificateProvider;
|
||||
import br.com.cybercore.signer.core.validation.SignatureValidationService;
|
||||
import br.com.cybercore.signer.core.validation.ValidationResult;
|
||||
import br.com.cybercore.signer.core.validation.ValidationStatus;
|
||||
import br.com.cybercore.signer.core.xml.XmlSignatureOptions;
|
||||
import br.com.cybercore.signer.core.xml.XmlSignatureProfile;
|
||||
import br.com.cybercore.signer.core.xml.XmlSigningService;
|
||||
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.ASN1EncodableVector;
|
||||
import org.bouncycastle.asn1.ASN1ObjectIdentifier;
|
||||
import org.bouncycastle.asn1.DEROctetString;
|
||||
import org.bouncycastle.asn1.DERSequence;
|
||||
import org.bouncycastle.asn1.x500.X500Name;
|
||||
import org.bouncycastle.asn1.x509.BasicConstraints;
|
||||
import org.bouncycastle.asn1.x509.Extension;
|
||||
import org.bouncycastle.asn1.x509.GeneralName;
|
||||
import org.bouncycastle.asn1.x509.GeneralNames;
|
||||
import org.bouncycastle.asn1.x509.KeyUsage;
|
||||
import org.bouncycastle.cert.X509CertificateHolder;
|
||||
import org.bouncycastle.cert.X509v3CertificateBuilder;
|
||||
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
|
||||
import org.bouncycastle.cert.jcajce.JcaX509ExtensionUtils;
|
||||
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
|
||||
import org.bouncycastle.cms.CMSProcessableByteArray;
|
||||
import org.bouncycastle.cms.CMSSignedData;
|
||||
import org.bouncycastle.cms.SignerInformation;
|
||||
import org.bouncycastle.cms.jcajce.JcaSimpleSignerInfoVerifierBuilder;
|
||||
import org.bouncycastle.operator.ContentSigner;
|
||||
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.w3c.dom.Document;
|
||||
import org.w3c.dom.Element;
|
||||
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.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.MessageDigest;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Suíte de testes automatizados de conformidade criptográfica para assinaturas
|
||||
* PAdES (PDF) e XAdES-BES / XMLDSig (XML), utilizando cadeia ICP-Brasil de teste gerada em memória.
|
||||
*/
|
||||
@DisplayName("Suíte de Testes de Conformidade ICP-Brasil (PAdES e XAdES-BES)")
|
||||
class ComplianceSignatureSuiteTest {
|
||||
|
||||
private static CertificateManager certificateManager;
|
||||
private static PdfSigningService pdfSigningService;
|
||||
private static XmlSigningService xmlSigningService;
|
||||
private static SignatureValidationService validationService;
|
||||
|
||||
private static String testEcpfCertId;
|
||||
private static X509Certificate rootCaCert;
|
||||
private static X509Certificate intermediateCaCert;
|
||||
private static X509Certificate signerCert;
|
||||
|
||||
@BeforeAll
|
||||
static void initTestingCaAndServices() throws Exception {
|
||||
certificateManager = new CertificateManager();
|
||||
validationService = new SignatureValidationService();
|
||||
|
||||
// 1. Gera par de chaves da AC Raiz ICP-Brasil de Teste
|
||||
KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
|
||||
kpg.initialize(2048);
|
||||
KeyPair rootKeyPair = kpg.generateKeyPair();
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
Date notBefore = new Date(now - 100_000);
|
||||
Date notAfter = new Date(now + 365L * 24 * 3600 * 1000);
|
||||
|
||||
X500Name rootSubject = new X500Name("CN=Autoridade Certificadora Raiz ICP-Brasil Teste v1, OU=Testes Criptograficos, O=ICP-Brasil, C=BR");
|
||||
X509v3CertificateBuilder rootBuilder = new JcaX509v3CertificateBuilder(
|
||||
rootSubject,
|
||||
BigInteger.valueOf(1),
|
||||
notBefore,
|
||||
notAfter,
|
||||
rootSubject,
|
||||
rootKeyPair.getPublic()
|
||||
);
|
||||
rootBuilder.addExtension(Extension.basicConstraints, true, new BasicConstraints(true));
|
||||
rootBuilder.addExtension(Extension.keyUsage, true, new KeyUsage(KeyUsage.keyCertSign | KeyUsage.cRLSign));
|
||||
|
||||
ContentSigner rootSigner = new JcaContentSignerBuilder("SHA256withRSA").build(rootKeyPair.getPrivate());
|
||||
rootCaCert = new JcaX509CertificateConverter().getCertificate(rootBuilder.build(rootSigner));
|
||||
|
||||
// 2. Gera AC Intermediária de Teste assinada pela Raiz
|
||||
KeyPair intermediateKeyPair = kpg.generateKeyPair();
|
||||
X500Name intermediateSubject = new X500Name("CN=AC Autoridade Certificadora Intermediaria Teste, OU=Testes ICP-Brasil, O=ICP-Brasil, C=BR");
|
||||
X509v3CertificateBuilder interBuilder = new JcaX509v3CertificateBuilder(
|
||||
rootSubject,
|
||||
BigInteger.valueOf(2),
|
||||
notBefore,
|
||||
notAfter,
|
||||
intermediateSubject,
|
||||
intermediateKeyPair.getPublic()
|
||||
);
|
||||
interBuilder.addExtension(Extension.basicConstraints, true, new BasicConstraints(true));
|
||||
interBuilder.addExtension(Extension.keyUsage, true, new KeyUsage(KeyUsage.keyCertSign | KeyUsage.cRLSign));
|
||||
|
||||
ContentSigner interSigner = new JcaContentSignerBuilder("SHA256withRSA").build(rootKeyPair.getPrivate());
|
||||
intermediateCaCert = new JcaX509CertificateConverter().getCertificate(interBuilder.build(interSigner));
|
||||
|
||||
// 3. Gera Certificado de Usuário Final (e-CPF com OID ICP-Brasil 2.16.76.1.3.1)
|
||||
KeyPair userKeyPair = kpg.generateKeyPair();
|
||||
X500Name userSubject = new X500Name("CN=CARLOS EDUARDO DA SILVA:12345678900, OU=e-CPF Brasil, O=ICP-Brasil, C=BR");
|
||||
X509v3CertificateBuilder userBuilder = new JcaX509v3CertificateBuilder(
|
||||
intermediateSubject,
|
||||
BigInteger.valueOf(3),
|
||||
notBefore,
|
||||
notAfter,
|
||||
userSubject,
|
||||
userKeyPair.getPublic()
|
||||
);
|
||||
userBuilder.addExtension(Extension.basicConstraints, true, new BasicConstraints(false));
|
||||
userBuilder.addExtension(Extension.keyUsage, true, new KeyUsage(KeyUsage.digitalSignature | KeyUsage.nonRepudiation));
|
||||
|
||||
// Adiciona extensão SubjectAlternativeName com OtherName contendo OID ICP-Brasil 2.16.76.1.3.1
|
||||
ASN1EncodableVector ecpfVector = new ASN1EncodableVector();
|
||||
ecpfVector.add(new ASN1ObjectIdentifier("2.16.76.1.3.1"));
|
||||
ecpfVector.add(new DEROctetString("01011990123456789001234567890123456789012345678".getBytes(StandardCharsets.UTF_8)));
|
||||
GeneralName otherName = new GeneralName(GeneralName.otherName, new DERSequence(ecpfVector));
|
||||
userBuilder.addExtension(Extension.subjectAlternativeName, false, new GeneralNames(otherName));
|
||||
|
||||
ContentSigner userSigner = new JcaContentSignerBuilder("SHA256withRSA").build(intermediateKeyPair.getPrivate());
|
||||
signerCert = new JcaX509CertificateConverter().getCertificate(userBuilder.build(userSigner));
|
||||
|
||||
// Carrega em KeyStore PKCS12 em memória com a cadeia completa
|
||||
KeyStore ks = KeyStore.getInstance("PKCS12");
|
||||
ks.load(null, null);
|
||||
char[] pwd = "test-compliance-pwd".toCharArray();
|
||||
ks.setKeyEntry("ecpf-compliance-cert", userKeyPair.getPrivate(), pwd, new Certificate[]{signerCert, intermediateCaCert, rootCaCert});
|
||||
|
||||
Pkcs12CertificateProvider provider = new Pkcs12CertificateProvider("compliance-p12-provider", "Provedor Compliance Teste");
|
||||
provider.setKeyStore(ks, pwd);
|
||||
certificateManager.registerProvider(provider);
|
||||
|
||||
List<ManagedCertificate> certs = certificateManager.discoverCertificates();
|
||||
testEcpfCertId = certs.get(0).id();
|
||||
|
||||
pdfSigningService = new PdfSigningService(certificateManager);
|
||||
xmlSigningService = new XmlSigningService(certificateManager);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PAdES: Deve gerar documento PDF assinado com envelope CMS desacoplado e validar conformidade estrutural")
|
||||
void shouldGenerateAndValidateCompliantPadesSignature() throws Exception {
|
||||
// Cria documento PDF em memória
|
||||
byte[] originalPdf;
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage();
|
||||
doc.addPage(page);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.beginText();
|
||||
cs.setFont(new org.apache.pdfbox.pdmodel.font.PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
cs.newLineAtOffset(50, 700);
|
||||
cs.showText("CONTRATO DE PRESTACAO DE SERVICOS - COMPLIANCE TEST");
|
||||
cs.endText();
|
||||
}
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
originalPdf = baos.toByteArray();
|
||||
}
|
||||
|
||||
// Assina via PdfSigningService
|
||||
PdfSignatureOptions options = PdfSignatureOptions.builder(testEcpfCertId)
|
||||
.position(PdfSealPosition.BOTTOM_RIGHT)
|
||||
.visibleSeal(true)
|
||||
.reason("Aprovacao Contratual")
|
||||
.location("Brasilia, DF")
|
||||
.build();
|
||||
byte[] signedPdf = pdfSigningService.signPdf(originalPdf, options);
|
||||
|
||||
assertThat(signedPdf).isNotEmpty();
|
||||
assertThat(signedPdf.length).isGreaterThan(originalPdf.length);
|
||||
|
||||
// 1. Validação independente via Apache PDFBox
|
||||
try (PDDocument signedDoc = Loader.loadPDF(signedPdf)) {
|
||||
List<PDSignature> signatures = signedDoc.getSignatureDictionaries();
|
||||
assertThat(signatures).hasSize(1);
|
||||
|
||||
PDSignature sig = signatures.get(0);
|
||||
assertThat(sig.getFilter()).isEqualTo("Adobe.PPKLite");
|
||||
assertThat(sig.getSubFilter()).isEqualTo("adbe.pkcs7.detached");
|
||||
assertThat(sig.getReason()).isEqualTo("Aprovacao Contratual");
|
||||
assertThat(sig.getLocation()).isEqualTo("Brasilia, DF");
|
||||
|
||||
int[] byteRange = sig.getByteRange();
|
||||
assertThat(byteRange).hasSize(4);
|
||||
assertThat(byteRange[0]).isEqualTo(0);
|
||||
assertThat(byteRange[1] + byteRange[3]).isEqualTo(signedPdf.length - (byteRange[2] - byteRange[1]));
|
||||
|
||||
// 2. Validação criptográfica do envelope CMS/PKCS#7 com Bouncy Castle independente
|
||||
byte[] signedContentBytes = sig.getSignedContent(signedPdf);
|
||||
byte[] cmsBytes = sig.getContents(signedPdf);
|
||||
CMSSignedData signedData = new CMSSignedData(new CMSProcessableByteArray(signedContentBytes), cmsBytes);
|
||||
|
||||
assertThat(signedData.getSignerInfos().getSigners()).hasSize(1);
|
||||
SignerInformation signerInfo = signedData.getSignerInfos().getSigners().iterator().next();
|
||||
|
||||
// Verifica se a assinatura bate com a chave pública do certificado do signatário
|
||||
boolean verified = signerInfo.verify(new JcaSimpleSignerInfoVerifierBuilder().build(signerCert));
|
||||
assertThat(verified).isTrue();
|
||||
|
||||
// Verifica se o digest embutido no CMS corresponde ao digest calculado dos bytes do documento
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||
byte[] calculatedDigest = md.digest(signedContentBytes);
|
||||
|
||||
byte[] messageDigestAttr = signerInfo.getSignedAttributes()
|
||||
.get(org.bouncycastle.asn1.cms.CMSAttributes.messageDigest)
|
||||
.getAttrValues().getObjectAt(0).toASN1Primitive().getEncoded();
|
||||
// Atributo ASN1 DER OctetString contém o digest
|
||||
assertThat(messageDigestAttr).contains(calculatedDigest);
|
||||
}
|
||||
|
||||
// 3. Validação através do SignatureValidationService do CyberCore
|
||||
ValidationResult valResult = validationService.validateAttached(signedPdf, "documento_assinado.pdf");
|
||||
assertThat(valResult.status()).isEqualTo(ValidationStatus.VALID);
|
||||
assertThat(valResult.signers()).hasSize(1);
|
||||
assertThat(valResult.signers().get(0).signerName()).contains("CARLOS EDUARDO DA SILVA");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("XAdES-BES: Deve gerar documento XML assinado com propriedades qualificadas e validar via JSR-105")
|
||||
void shouldGenerateAndValidateCompliantXadesBesSignature() throws Exception {
|
||||
String originalXml = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<declaracao id="dec-9988">
|
||||
<titular>Carlos Eduardo da Silva</titular>
|
||||
<cpf>12345678900</cpf>
|
||||
<cidade>Brasilia</cidade>
|
||||
</declaracao>
|
||||
""";
|
||||
|
||||
XmlSignatureOptions options = XmlSignatureOptions.wholeDocument(testEcpfCertId, null, XmlSignatureProfile.XADES_BES);
|
||||
|
||||
byte[] signedXmlBytes = xmlSigningService.signXml(originalXml.getBytes(StandardCharsets.UTF_8), options);
|
||||
String signedXmlStr = new String(signedXmlBytes, StandardCharsets.UTF_8);
|
||||
|
||||
// 1. Verificação de tags obrigatórias do XAdES-BES (ETSI TS 101 903 e DOC-ICP-15.03)
|
||||
assertThat(signedXmlStr).contains("<Signature xmlns=\"http://www.w3.org/2000/09/xmldsig#\"");
|
||||
assertThat(signedXmlStr).contains("<xades:QualifyingProperties");
|
||||
assertThat(signedXmlStr).contains("<xades:SignedProperties");
|
||||
assertThat(signedXmlStr).contains("<xades:SigningTime");
|
||||
assertThat(signedXmlStr).contains("<xades:SigningCertificate");
|
||||
assertThat(signedXmlStr).contains("http://uri.etsi.org/01903#SignedProperties");
|
||||
|
||||
// 2. Validação criptográfica independente via API JSR-105 (DOMValidateContext)
|
||||
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
|
||||
dbf.setNamespaceAware(true);
|
||||
Document doc = dbf.newDocumentBuilder().parse(new ByteArrayInputStream(signedXmlBytes));
|
||||
|
||||
NodeList sigNodes = doc.getElementsByTagNameNS(XMLSignature.XMLNS, "Signature");
|
||||
assertThat(sigNodes.getLength()).isEqualTo(1);
|
||||
|
||||
DOMValidateContext valContext = new DOMValidateContext(signerCert.getPublicKey(), sigNodes.item(0));
|
||||
NodeList allNodes = doc.getElementsByTagName("*");
|
||||
for (int n = 0; n < allNodes.getLength(); n++) {
|
||||
if (allNodes.item(n) instanceof Element e) {
|
||||
if (e.hasAttribute("Id")) {
|
||||
valContext.setIdAttributeNS(e, null, "Id");
|
||||
}
|
||||
if (e.hasAttribute("id")) {
|
||||
valContext.setIdAttributeNS(e, null, "id");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
XMLSignatureFactory sigFactory = XMLSignatureFactory.getInstance("DOM");
|
||||
XMLSignature xmlSignature = sigFactory.unmarshalXMLSignature(valContext);
|
||||
|
||||
boolean coreValidity = xmlSignature.validate(valContext);
|
||||
assertThat(coreValidity).isTrue();
|
||||
|
||||
// 3. Validação pós-adulteração: se alterar qualquer caractere do XML original, a assinatura deve falhar
|
||||
byte[] tamperedXml = signedXmlStr.replace("Carlos Eduardo da Silva", "Atacante Malicioso").getBytes(StandardCharsets.UTF_8);
|
||||
Document tamperedDoc = dbf.newDocumentBuilder().parse(new ByteArrayInputStream(tamperedXml));
|
||||
NodeList tamperedSigNodes = tamperedDoc.getElementsByTagNameNS(XMLSignature.XMLNS, "Signature");
|
||||
DOMValidateContext tamperedContext = new DOMValidateContext(signerCert.getPublicKey(), tamperedSigNodes.item(0));
|
||||
NodeList allTamperedNodes = tamperedDoc.getElementsByTagName("*");
|
||||
for (int n = 0; n < allTamperedNodes.getLength(); n++) {
|
||||
if (allTamperedNodes.item(n) instanceof Element e) {
|
||||
if (e.hasAttribute("Id")) {
|
||||
tamperedContext.setIdAttributeNS(e, null, "Id");
|
||||
}
|
||||
if (e.hasAttribute("id")) {
|
||||
tamperedContext.setIdAttributeNS(e, null, "id");
|
||||
}
|
||||
}
|
||||
}
|
||||
XMLSignature tamperedSig = sigFactory.unmarshalXMLSignature(tamperedContext);
|
||||
|
||||
boolean tamperedValidity = tamperedSig.validate(tamperedContext);
|
||||
assertThat(tamperedValidity).isFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package br.com.cybercore.signer.core.crypto.revocation;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@DisplayName("Testes do RevocationCache com TTL")
|
||||
class RevocationCacheTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("Deve armazenar e recuperar valor válido dentro do TTL")
|
||||
void shouldStoreAndRetrieveValidValue() {
|
||||
RevocationCache<String, String> cache = new RevocationCache<>();
|
||||
cache.put("cert-123", "VALID_OCSP_RESPONSE", Duration.ofMinutes(10));
|
||||
|
||||
Optional<String> result = cache.get("cert-123");
|
||||
assertThat(result).isPresent().contains("VALID_OCSP_RESPONSE");
|
||||
assertThat(cache.size()).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Deve descartar valor expirado após o TTL")
|
||||
void shouldExpireValueAfterTtl() throws InterruptedException {
|
||||
RevocationCache<String, String> cache = new RevocationCache<>();
|
||||
cache.put("cert-exp", "TEMPORARY", Duration.ofMillis(20));
|
||||
|
||||
Thread.sleep(50);
|
||||
|
||||
Optional<String> result = cache.get("cert-exp");
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getOrFetch deve invocar o fetcher apenas uma vez enquanto o cache estiver válido")
|
||||
void shouldInvokeFetcherOnlyOnceWhileValid() {
|
||||
RevocationCache<String, Integer> cache = new RevocationCache<>();
|
||||
AtomicInteger counter = new AtomicInteger(0);
|
||||
|
||||
Integer firstCall = cache.getOrFetch("key", Duration.ofMinutes(5), counter::incrementAndGet);
|
||||
Integer secondCall = cache.getOrFetch("key", Duration.ofMinutes(5), counter::incrementAndGet);
|
||||
|
||||
assertThat(firstCall).isEqualTo(1);
|
||||
assertThat(secondCall).isEqualTo(1);
|
||||
assertThat(counter.get()).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("evictExpired e clear devem limpar as entradas corretamente")
|
||||
void shouldEvictExpiredAndClearCorrectly() throws InterruptedException {
|
||||
RevocationCache<String, String> cache = new RevocationCache<>();
|
||||
cache.put("k1", "val1", Duration.ofMillis(20));
|
||||
cache.put("k2", "val2", Duration.ofMinutes(10));
|
||||
|
||||
Thread.sleep(50);
|
||||
cache.evictExpired();
|
||||
|
||||
assertThat(cache.get("k1")).isEmpty();
|
||||
assertThat(cache.get("k2")).isPresent();
|
||||
|
||||
cache.clear();
|
||||
assertThat(cache.size()).isZero();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package br.com.cybercore.signer.core.crypto.tsa;
|
||||
|
||||
import br.com.cybercore.signer.core.protocol.CcspErrorCode;
|
||||
import br.com.cybercore.signer.core.provider.CertificateProviderException;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import org.bouncycastle.asn1.x500.X500Name;
|
||||
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;
|
||||
import org.bouncycastle.cert.X509CertificateHolder;
|
||||
import org.bouncycastle.cert.X509v3CertificateBuilder;
|
||||
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
|
||||
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.bouncycastle.tsp.TSPAlgorithms;
|
||||
import org.bouncycastle.tsp.TimeStampRequest;
|
||||
import org.bouncycastle.tsp.TimeStampResponse;
|
||||
import org.bouncycastle.tsp.TimeStampResponseGenerator;
|
||||
import org.bouncycastle.tsp.TimeStampToken;
|
||||
import org.bouncycastle.tsp.TimeStampTokenGenerator;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.math.BigInteger;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
@DisplayName("Testes do Cliente Resiliente de Carimbo do Tempo (TSA RFC 3161)")
|
||||
class TsaClientTest {
|
||||
|
||||
private static KeyPair tsaKeyPair;
|
||||
private static X509Certificate tsaCert;
|
||||
private static HttpServer primaryServer;
|
||||
private static HttpServer secondaryServer;
|
||||
private static String primaryUrl;
|
||||
private static String secondaryUrl;
|
||||
private static final AtomicInteger primaryRequestCount = new AtomicInteger(0);
|
||||
private static final AtomicInteger secondaryRequestCount = new AtomicInteger(0);
|
||||
|
||||
@BeforeAll
|
||||
static void setUp() throws Exception {
|
||||
// Gera par de chaves e certificado para a TSA de testes
|
||||
KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
|
||||
kpg.initialize(2048);
|
||||
tsaKeyPair = kpg.generateKeyPair();
|
||||
|
||||
X500Name subject = new X500Name("CN=Autoridade de Carimbo do Tempo de Teste, O=ICP-Brasil, C=BR");
|
||||
BigInteger serial = BigInteger.valueOf(101);
|
||||
Date notBefore = Date.from(Instant.now().minus(1, ChronoUnit.DAYS));
|
||||
Date notAfter = Date.from(Instant.now().plus(365, ChronoUnit.DAYS));
|
||||
|
||||
SubjectPublicKeyInfo subPubKeyInfo = SubjectPublicKeyInfo.getInstance(tsaKeyPair.getPublic().getEncoded());
|
||||
X509v3CertificateBuilder certBuilder = new X509v3CertificateBuilder(
|
||||
subject, serial, notBefore, notAfter, subject, subPubKeyInfo
|
||||
);
|
||||
certBuilder.addExtension(
|
||||
org.bouncycastle.asn1.x509.Extension.extendedKeyUsage,
|
||||
true,
|
||||
new org.bouncycastle.asn1.x509.ExtendedKeyUsage(org.bouncycastle.asn1.x509.KeyPurposeId.id_kp_timeStamping)
|
||||
);
|
||||
ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").build(tsaKeyPair.getPrivate());
|
||||
X509CertificateHolder holder = certBuilder.build(signer);
|
||||
tsaCert = new JcaX509CertificateConverter().getCertificate(holder);
|
||||
|
||||
// Servidor primário
|
||||
primaryServer = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
primaryServer.createContext("/tsa", exchange -> {
|
||||
int count = primaryRequestCount.incrementAndGet();
|
||||
String query = exchange.getRequestURI().getQuery();
|
||||
if (query != null && query.contains("error")) {
|
||||
exchange.sendResponseHeaders(503, -1);
|
||||
return;
|
||||
}
|
||||
if (query != null && query.contains("flaky") && count == 1) {
|
||||
// Primeira tentativa falha, segunda sucede
|
||||
exchange.sendResponseHeaders(500, -1);
|
||||
return;
|
||||
}
|
||||
try (InputStream is = exchange.getRequestBody()) {
|
||||
byte[] reqBytes = is.readAllBytes();
|
||||
byte[] respBytes = generateTsaResponse(reqBytes);
|
||||
exchange.getResponseHeaders().set("Content-Type", "application/timestamp-reply");
|
||||
exchange.sendResponseHeaders(200, respBytes.length);
|
||||
try (OutputStream os = exchange.getResponseBody()) {
|
||||
os.write(respBytes);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
exchange.sendResponseHeaders(500, -1);
|
||||
}
|
||||
});
|
||||
primaryServer.start();
|
||||
primaryUrl = "http://127.0.0.1:" + primaryServer.getAddress().getPort() + "/tsa";
|
||||
|
||||
// Servidor secundário (Fallback)
|
||||
secondaryServer = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
secondaryServer.createContext("/tsa", exchange -> {
|
||||
secondaryRequestCount.incrementAndGet();
|
||||
try (InputStream is = exchange.getRequestBody()) {
|
||||
byte[] reqBytes = is.readAllBytes();
|
||||
byte[] respBytes = generateTsaResponse(reqBytes);
|
||||
exchange.getResponseHeaders().set("Content-Type", "application/timestamp-reply");
|
||||
exchange.sendResponseHeaders(200, respBytes.length);
|
||||
try (OutputStream os = exchange.getResponseBody()) {
|
||||
os.write(respBytes);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
exchange.sendResponseHeaders(500, -1);
|
||||
}
|
||||
});
|
||||
secondaryServer.start();
|
||||
secondaryUrl = "http://127.0.0.1:" + secondaryServer.getAddress().getPort() + "/tsa";
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void tearDown() {
|
||||
if (primaryServer != null) primaryServer.stop(0);
|
||||
if (secondaryServer != null) secondaryServer.stop(0);
|
||||
}
|
||||
|
||||
private static byte[] generateTsaResponse(byte[] requestBytes) throws Exception {
|
||||
TimeStampRequest req = new TimeStampRequest(requestBytes);
|
||||
org.bouncycastle.operator.DigestCalculatorProvider digCalcProv = new JcaDigestCalculatorProviderBuilder().build();
|
||||
org.bouncycastle.operator.DigestCalculator digCalc = digCalcProv.get(new org.bouncycastle.asn1.x509.AlgorithmIdentifier(org.bouncycastle.asn1.nist.NISTObjectIdentifiers.id_sha256));
|
||||
|
||||
org.bouncycastle.cms.SignerInfoGenerator signerInfoGen = new JcaSignerInfoGeneratorBuilder(digCalcProv)
|
||||
.build(new JcaContentSignerBuilder("SHA256withRSA").build(tsaKeyPair.getPrivate()), tsaCert);
|
||||
|
||||
TimeStampTokenGenerator tokenGen = new TimeStampTokenGenerator(
|
||||
signerInfoGen,
|
||||
digCalc,
|
||||
new org.bouncycastle.asn1.ASN1ObjectIdentifier("2.16.76.1.4.1") // OID Política ACT ICP-Brasil
|
||||
);
|
||||
TimeStampResponseGenerator respGen = new TimeStampResponseGenerator(tokenGen, java.util.Collections.singleton(TSPAlgorithms.SHA256));
|
||||
TimeStampResponse resp = respGen.generate(req, BigInteger.valueOf(System.currentTimeMillis()), new Date());
|
||||
return resp.getEncoded();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Deve emitir carimbo de tempo RFC 3161 com sucesso no servidor primário")
|
||||
void shouldObtainTimestampSuccessfullyFromPrimary() throws Exception {
|
||||
primaryRequestCount.set(0);
|
||||
TsaClient client = new TsaClient(List.of(primaryUrl));
|
||||
|
||||
byte[] digest = MessageDigest.getInstance("SHA-256").digest("teste-tsa".getBytes(StandardCharsets.UTF_8));
|
||||
TimeStampToken token = client.requestTimestamp(digest);
|
||||
|
||||
assertThat(token).isNotNull();
|
||||
assertThat(token.getTimeStampInfo().getGenTime()).isNotNull();
|
||||
assertThat(primaryRequestCount.get()).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Deve realizar retentativa com backoff quando o servidor primário oscilar temporariamente")
|
||||
void shouldRetryWithBackoffOnTemporaryFailure() throws Exception {
|
||||
primaryRequestCount.set(0);
|
||||
String flakyUrl = primaryUrl + "?flaky=true";
|
||||
TsaClient client = new TsaClient(List.of(flakyUrl), Duration.ofSeconds(2), 2, Duration.ofMillis(50), null);
|
||||
|
||||
byte[] digest = MessageDigest.getInstance("SHA-256").digest("teste-retry".getBytes(StandardCharsets.UTF_8));
|
||||
TimeStampToken token = client.requestTimestamp(digest);
|
||||
|
||||
assertThat(token).isNotNull();
|
||||
assertThat(primaryRequestCount.get()).isEqualTo(2); // 1ª falhou, 2ª sucedeu
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Deve fazer fallback transparente para o servidor secundário quando o primário estiver indisponível")
|
||||
void shouldFallbackToSecondaryServerWhenPrimaryFails() throws Exception {
|
||||
secondaryRequestCount.set(0);
|
||||
String offlineUrl = primaryUrl + "?error=true";
|
||||
TsaClient client = new TsaClient(List.of(offlineUrl, secondaryUrl), Duration.ofSeconds(2), 1, Duration.ofMillis(20), null);
|
||||
|
||||
byte[] digest = MessageDigest.getInstance("SHA-256").digest("teste-fallback".getBytes(StandardCharsets.UTF_8));
|
||||
TimeStampToken token = client.requestTimestamp(digest);
|
||||
|
||||
assertThat(token).isNotNull();
|
||||
assertThat(secondaryRequestCount.get()).isGreaterThanOrEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Deve lançar COMMUNICATION_ERROR se todos os servidores falharem")
|
||||
void shouldThrowCommunicationErrorWhenAllServersFail() throws Exception {
|
||||
String offlineUrl1 = primaryUrl + "?error=true";
|
||||
String offlineUrl2 = "http://127.0.0.1:54321/offline";
|
||||
TsaClient client = new TsaClient(List.of(offlineUrl1, offlineUrl2), Duration.ofMillis(500), 0, Duration.ofMillis(10), null);
|
||||
|
||||
byte[] digest = MessageDigest.getInstance("SHA-256").digest("teste-falha-total".getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
assertThatThrownBy(() -> client.requestTimestamp(digest))
|
||||
.isInstanceOf(CertificateProviderException.class)
|
||||
.satisfies(ex -> {
|
||||
CertificateProviderException cpe = (CertificateProviderException) ex;
|
||||
assertThat(cpe.getErrorCode()).isEqualTo(CcspErrorCode.COMMUNICATION_ERROR);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,8 @@ import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
|
||||
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
|
||||
import org.bouncycastle.operator.ContentSigner;
|
||||
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
|
||||
import br.com.cybercore.signer.core.validation.SignatureValidationService;
|
||||
import br.com.cybercore.signer.core.validation.ValidationResult;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -41,6 +43,7 @@ class PdfSigningServiceTest {
|
||||
private CertificateManager certificateManager;
|
||||
private PdfSigningService pdfSigningService;
|
||||
private String testCertId;
|
||||
private String testCert2Id;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
@@ -74,13 +77,29 @@ class PdfSigningServiceTest {
|
||||
char[] pwd = "password123".toCharArray();
|
||||
ks.setKeyEntry("test-cert", kp.getPrivate(), pwd, new Certificate[]{cert});
|
||||
|
||||
// Gera segundo par de chaves e certificado para co-assinatura
|
||||
KeyPair kp2 = kpg.generateKeyPair();
|
||||
X500Name subject2 = new X500Name("CN=Maria Souza:98765432100, O=CyberCore Testes, C=BR");
|
||||
X509v3CertificateBuilder certBuilder2 = new JcaX509v3CertificateBuilder(
|
||||
subject2,
|
||||
BigInteger.valueOf(System.currentTimeMillis() + 100),
|
||||
notBefore,
|
||||
notAfter,
|
||||
subject2,
|
||||
kp2.getPublic()
|
||||
);
|
||||
ContentSigner signer2 = new JcaContentSignerBuilder("SHA256withRSA").build(kp2.getPrivate());
|
||||
X509Certificate cert2 = new JcaX509CertificateConverter().getCertificate(certBuilder2.build(signer2));
|
||||
ks.setKeyEntry("test-cert-2", kp2.getPrivate(), pwd, new Certificate[]{cert2});
|
||||
|
||||
Pkcs12CertificateProvider provider = new Pkcs12CertificateProvider("test-provider", "Provedor de Teste");
|
||||
provider.setKeyStore(ks, pwd);
|
||||
certificateManager.registerProvider(provider);
|
||||
|
||||
List<ManagedCertificate> list = certificateManager.discoverCertificates();
|
||||
assertThat(list).isNotEmpty();
|
||||
this.testCertId = list.get(0).id();
|
||||
assertThat(list).hasSize(2);
|
||||
this.testCertId = list.stream().filter(c -> c.alias().equals("test-cert")).findFirst().orElseThrow().id();
|
||||
this.testCert2Id = list.stream().filter(c -> c.alias().equals("test-cert-2")).findFirst().orElseThrow().id();
|
||||
|
||||
this.pdfSigningService = new PdfSigningService(certificateManager);
|
||||
}
|
||||
@@ -215,4 +234,173 @@ class PdfSigningServiceTest {
|
||||
byte[] pngBytes = PdfSigningService.renderPagePngBytes(originalPdf, 0, 72f);
|
||||
assertThat(pngBytes).isNotEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Deve manter ambas as assinaturas válidas ao assinar um PDF que já possui assinatura digital (Co-assinatura PAdES)")
|
||||
void shouldKeepBothSignaturesValidWhenSigningAlreadySignedPdf() throws Exception {
|
||||
byte[] originalPdf = createSamplePdf();
|
||||
|
||||
// 1ª Assinatura com o primeiro certificado
|
||||
PdfSignatureOptions firstOptions = PdfSignatureOptions.builder(testCertId)
|
||||
.visibleSeal(true)
|
||||
.position(PdfSealPosition.BOTTOM_RIGHT)
|
||||
.reason("Primeira Aprovação")
|
||||
.location("Brasília, DF")
|
||||
.build();
|
||||
|
||||
byte[] firstSignedPdf = pdfSigningService.signPdf(originalPdf, firstOptions);
|
||||
assertThat(firstSignedPdf).isNotNull();
|
||||
|
||||
// 2ª Assinatura sobre o documento já assinado com o segundo certificado
|
||||
PdfSignatureOptions secondOptions = PdfSignatureOptions.builder(testCert2Id)
|
||||
.visibleSeal(true)
|
||||
.position(PdfSealPosition.TOP_LEFT)
|
||||
.reason("Segunda Aprovação Conjunta")
|
||||
.location("São Paulo, SP")
|
||||
.build();
|
||||
|
||||
byte[] doubleSignedPdf = pdfSigningService.signPdf(firstSignedPdf, secondOptions);
|
||||
assertThat(doubleSignedPdf).isNotNull();
|
||||
assertThat(doubleSignedPdf.length).isGreaterThan(firstSignedPdf.length);
|
||||
|
||||
// Validação estrutural das assinaturas no PDDocument
|
||||
try (PDDocument doc = Loader.loadPDF(doubleSignedPdf)) {
|
||||
List<PDSignature> signatures = doc.getSignatureDictionaries();
|
||||
assertThat(signatures).hasSize(2);
|
||||
assertThat(signatures.get(0).getName()).contains("Joao da Silva");
|
||||
assertThat(signatures.get(0).getReason()).isEqualTo("Primeira Aprovação");
|
||||
assertThat(signatures.get(1).getName()).contains("Maria Souza");
|
||||
assertThat(signatures.get(1).getReason()).isEqualTo("Segunda Aprovação Conjunta");
|
||||
}
|
||||
|
||||
// Validação criptográfica rigorosa via SignatureValidationService (ambas as assinaturas devem ser íntegras)
|
||||
SignatureValidationService validationService = new SignatureValidationService();
|
||||
ValidationResult validationResult = validationService.validateAttached(doubleSignedPdf, "duplo_assinado.pdf");
|
||||
|
||||
assertThat(validationResult.isValid()).isTrue();
|
||||
assertThat(validationResult.signers()).hasSize(2);
|
||||
|
||||
// Ambas as assinaturas e digests devem ser 100% válidos
|
||||
assertThat(validationResult.signers().get(0).signatureValid()).isTrue();
|
||||
assertThat(validationResult.signers().get(0).digestValid()).isTrue();
|
||||
assertThat(validationResult.signers().get(0).signerName()).contains("Joao da Silva");
|
||||
|
||||
assertThat(validationResult.signers().get(1).signatureValid()).isTrue();
|
||||
assertThat(validationResult.signers().get(1).digestValid()).isTrue();
|
||||
assertThat(validationResult.signers().get(1).signerName()).contains("Maria Souza");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Deve assinar arquivo PDF via signPdfFile com arquivos de origem e destino distintos")
|
||||
void shouldSignPdfFileWithDistinctSourceAndTarget() throws Exception {
|
||||
java.io.File src = java.io.File.createTempFile("cybercore_src_", ".pdf");
|
||||
java.io.File tgt = java.io.File.createTempFile("cybercore_tgt_", ".pdf");
|
||||
try {
|
||||
java.nio.file.Files.write(src.toPath(), createSamplePdf());
|
||||
|
||||
PdfSignatureOptions options = PdfSignatureOptions.builder(testCertId)
|
||||
.visibleSeal(true)
|
||||
.position(PdfSealPosition.BOTTOM_RIGHT)
|
||||
.reason("Assinatura de Arquivo Físico")
|
||||
.location("Brasília, DF")
|
||||
.build();
|
||||
|
||||
pdfSigningService.signPdfFile(src, tgt, options);
|
||||
|
||||
assertThat(tgt.exists()).isTrue();
|
||||
assertThat(tgt.length()).isGreaterThan(src.length());
|
||||
|
||||
// Valida leitura de páginas e dimensões a partir de File
|
||||
assertThat(PdfSigningService.getPageCount(src)).isEqualTo(1);
|
||||
assertThat(PdfSigningService.getPageCount(tgt)).isEqualTo(1);
|
||||
PdfSigningService.PageDimensions dims = PdfSigningService.getPageDimensions(tgt, 0);
|
||||
assertThat(dims.width()).isGreaterThan(0);
|
||||
assertThat(dims.height()).isGreaterThan(0);
|
||||
|
||||
// Validação criptográfica do arquivo resultante
|
||||
byte[] signedBytes = java.nio.file.Files.readAllBytes(tgt.toPath());
|
||||
SignatureValidationService validationService = new SignatureValidationService();
|
||||
ValidationResult res = validationService.validateAttached(signedBytes, "arquivo_assinado.pdf");
|
||||
assertThat(res.isValid()).isTrue();
|
||||
} finally {
|
||||
java.nio.file.Files.deleteIfExists(src.toPath());
|
||||
java.nio.file.Files.deleteIfExists(tgt.toPath());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Deve assinar arquivo PDF in-place (mesmo arquivo de origem e destino)")
|
||||
void shouldSignInPlaceWhenSourceAndTargetAreSameFile() throws Exception {
|
||||
java.io.File file = java.io.File.createTempFile("cybercore_inplace_", ".pdf");
|
||||
try {
|
||||
byte[] sample = createSamplePdf();
|
||||
java.nio.file.Files.write(file.toPath(), sample);
|
||||
long initialSize = file.length();
|
||||
|
||||
PdfSignatureOptions options = PdfSignatureOptions.builder(testCertId)
|
||||
.visibleSeal(false)
|
||||
.reason("Assinatura In-Place")
|
||||
.build();
|
||||
|
||||
pdfSigningService.signPdfFile(file, file, options);
|
||||
|
||||
assertThat(file.exists()).isTrue();
|
||||
assertThat(file.length()).isGreaterThan(initialSize);
|
||||
|
||||
byte[] signedBytes = java.nio.file.Files.readAllBytes(file.toPath());
|
||||
SignatureValidationService validationService = new SignatureValidationService();
|
||||
ValidationResult res = validationService.validateAttached(signedBytes, "inplace.pdf");
|
||||
assertThat(res.isValid()).isTrue();
|
||||
} finally {
|
||||
java.nio.file.Files.deleteIfExists(file.toPath());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Deve assinar PDF grande (> 2MB) acionando a estratégia de arquivo temporário")
|
||||
void shouldSignLargePdfTriggeringTempFileOptimization() throws Exception {
|
||||
// Cria PDF com buffer grande embutido para ultrapassar 2MB
|
||||
java.io.ByteArrayOutputStream baos = new java.io.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), 10);
|
||||
cs.newLineAtOffset(50, 700);
|
||||
cs.showText("PDF com carga útil volumosa para teste de threshold de streaming.");
|
||||
cs.endText();
|
||||
}
|
||||
// Anexa stream arbitrário para elevar o tamanho acima de 2MB
|
||||
org.apache.pdfbox.pdmodel.common.PDStream stream = new org.apache.pdfbox.pdmodel.common.PDStream(doc);
|
||||
try (java.io.OutputStream os = stream.createOutputStream()) {
|
||||
byte[] chunk = new byte[65536];
|
||||
java.util.Arrays.fill(chunk, (byte) 'X');
|
||||
for (int i = 0; i < 35; i++) { // ~2.2 MB
|
||||
os.write(chunk);
|
||||
}
|
||||
}
|
||||
doc.getDocumentCatalog().getCOSObject().setItem(
|
||||
org.apache.pdfbox.cos.COSName.getPDFName("LargePayload"), stream.getCOSObject()
|
||||
);
|
||||
doc.save(baos);
|
||||
}
|
||||
|
||||
byte[] largePdf = baos.toByteArray();
|
||||
assertThat(largePdf.length).isGreaterThan(2 * 1024 * 1024);
|
||||
|
||||
PdfSignatureOptions options = PdfSignatureOptions.builder(testCertId)
|
||||
.visibleSeal(true)
|
||||
.position(PdfSealPosition.BOTTOM_RIGHT)
|
||||
.reason("Assinatura de Arquivo Grande")
|
||||
.build();
|
||||
|
||||
byte[] signed = pdfSigningService.signPdf(largePdf, options);
|
||||
assertThat(signed).isNotNull();
|
||||
assertThat(signed.length).isGreaterThan(largePdf.length);
|
||||
|
||||
SignatureValidationService validationService = new SignatureValidationService();
|
||||
ValidationResult res = validationService.validateAttached(signed, "large_signed.pdf");
|
||||
assertThat(res.isValid()).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
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.SigningKeyMaterial;
|
||||
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 com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
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.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.math.BigInteger;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
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 Validação Cruzada contra o JSON Schema CCSP/1 (ccsp-v1.schema.json)")
|
||||
class CcspSchemaValidationTest {
|
||||
|
||||
private CcspServer server;
|
||||
private HttpClient httpClient;
|
||||
private String baseUrl;
|
||||
private ObjectMapper objectMapper;
|
||||
private JsonNode schemaRoot;
|
||||
|
||||
@BeforeEach
|
||||
void setUp(@TempDir Path tempDir) throws Exception {
|
||||
objectMapper = new ObjectMapper();
|
||||
|
||||
// Localiza e carrega docs/ccsp-v1.schema.json
|
||||
Path schemaPath = Path.of("../../docs/ccsp-v1.schema.json");
|
||||
if (!Files.exists(schemaPath)) {
|
||||
schemaPath = Path.of("docs/ccsp-v1.schema.json");
|
||||
}
|
||||
assertThat(schemaPath).exists();
|
||||
schemaRoot = objectMapper.readTree(Files.readString(schemaPath, StandardCharsets.UTF_8));
|
||||
|
||||
CertificateManager certManager = new CertificateManager();
|
||||
SigningService signingService = mock(SigningService.class);
|
||||
ApplicationRegistry appRegistry = new ApplicationRegistry(tempDir.resolve("apps.json"));
|
||||
|
||||
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=Schema Test ICP-Brasil:12345678901, O=CyberCore, 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));
|
||||
|
||||
appRegistry.register(new AuthorizedApplication(
|
||||
"app-schema-test",
|
||||
"Schema Validation Test App",
|
||||
Set.of("http://localhost:3000", "http://127.0.0.1:3000"),
|
||||
true,
|
||||
Instant.now()
|
||||
));
|
||||
|
||||
ManagedCertificate testCert = new ManagedCertificate(
|
||||
"cert-schema-1",
|
||||
"Certificado Schema Teste",
|
||||
CertificateType.A1,
|
||||
CertificateStatus.AVAILABLE,
|
||||
"CN=Schema Test ICP-Brasil:12345678901, O=CyberCore, C=BR",
|
||||
"CN=Schema Test ICP-Brasil:12345678901, O=CyberCore, C=BR",
|
||||
"12345678",
|
||||
"a1b2c3",
|
||||
"d4e5f6",
|
||||
Instant.now().minus(10, ChronoUnit.DAYS),
|
||||
Instant.now().plus(300, ChronoUnit.DAYS),
|
||||
"SHA256withRSA",
|
||||
2048,
|
||||
"Schema Test ICP-Brasil",
|
||||
null,
|
||||
null,
|
||||
"provider-schema-test",
|
||||
"Certificado Schema Teste"
|
||||
);
|
||||
|
||||
CertificateProvider dummyProvider = new CertificateProvider() {
|
||||
@Override public String getProviderId() { return "provider-schema-test"; }
|
||||
@Override public String getDisplayName() { return "Provedor Teste Schema"; }
|
||||
@Override public ProviderType getType() { return ProviderType.A1_FILE; }
|
||||
@Override public boolean isAvailable() { return true; }
|
||||
@Override public List<ManagedCertificate> 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());
|
||||
}
|
||||
@Override public Optional<SigningKeyMaterial> getKeyMaterial(String certificateId, char[] pinOrPassword) {
|
||||
return Optional.of(new SigningKeyMaterial(kp.getPrivate(), new X509Certificate[]{cert}, null));
|
||||
}
|
||||
};
|
||||
|
||||
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())
|
||||
);
|
||||
|
||||
server = new CcspServer(0, certManager, signingService, appRegistry, new CcspUserPromptListener() {
|
||||
@Override
|
||||
public CompletableFuture<Boolean> requestApplicationAuthorization(String origin, String appName, String applicationId) {
|
||||
return CompletableFuture.completedFuture(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<Boolean> requestSignatureConfirmation(CcspRequest request, ManagedCertificate certificate) {
|
||||
return CompletableFuture.completedFuture(true);
|
||||
}
|
||||
});
|
||||
|
||||
server.start();
|
||||
int port = server.getPort();
|
||||
baseUrl = "http://127.0.0.1:" + port;
|
||||
httpClient = HttpClient.newHttpClient();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
if (server != null) {
|
||||
server.stop();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Garante integridade e completude do catálogo de ErrorCode no schema formal")
|
||||
void shouldValidateErrorCodeCatalogInSchema() {
|
||||
JsonNode errorCodeEnum = schemaRoot.path("definitions").path("ErrorCode").path("enum");
|
||||
assertThat(errorCodeEnum.isArray()).isTrue();
|
||||
|
||||
Set<String> errorCodes = new HashSet<>();
|
||||
errorCodeEnum.forEach(node -> errorCodes.add(node.asText()));
|
||||
|
||||
assertThat(errorCodes).contains(
|
||||
"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",
|
||||
"COMMUNICATION_ERROR",
|
||||
"AGENT_NOT_RUNNING",
|
||||
"TIMEOUT_ERROR",
|
||||
"BAD_REQUEST"
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Valida resposta do endpoint /ccsp/v1/status contra a definição StatusResponse")
|
||||
void shouldValidateStatusEndpointResponseAgainstSchema() throws Exception {
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(baseUrl + "/ccsp/v1/status"))
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
assertThat(response.statusCode()).isEqualTo(200);
|
||||
|
||||
JsonNode json = objectMapper.readTree(response.body());
|
||||
validateAgainstDefinition(json, "StatusResponse");
|
||||
|
||||
assertThat(json.path("version").asText()).isEqualTo("1");
|
||||
assertThat(json.path("status").asText()).isEqualTo("ready");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Valida resposta do endpoint /ccsp/v1/authorize contra a definição AuthorizeResponse")
|
||||
void shouldValidateAuthorizeEndpointResponseAgainstSchema() throws Exception {
|
||||
String payload = """
|
||||
{
|
||||
"version": "1",
|
||||
"application_id": "app-schema-test",
|
||||
"name": "Schema Validation Test App"
|
||||
}
|
||||
""";
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(baseUrl + "/ccsp/v1/authorize"))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Origin", "http://localhost:3000")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(payload))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
assertThat(response.statusCode()).isEqualTo(200);
|
||||
|
||||
JsonNode json = objectMapper.readTree(response.body());
|
||||
validateAgainstDefinition(json, "AuthorizeResponse");
|
||||
|
||||
assertThat(json.path("version").asText()).isEqualTo("1");
|
||||
assertThat(json.path("status").asText()).isEqualTo("authorized");
|
||||
assertThat(json.path("application_id").asText()).isEqualTo("app-schema-test");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Valida resposta do endpoint /ccsp/v1/certificates contra CertificatesResponse e CertificateDto")
|
||||
void shouldValidateCertificatesEndpointResponseAgainstSchema() throws Exception {
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(baseUrl + "/ccsp/v1/certificates?application_id=app-schema-test"))
|
||||
.header("Origin", "http://localhost:3000")
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
assertThat(response.statusCode()).isEqualTo(200);
|
||||
|
||||
JsonNode json = objectMapper.readTree(response.body());
|
||||
validateAgainstDefinition(json, "CertificatesResponse");
|
||||
|
||||
JsonNode certificates = json.path("certificates");
|
||||
assertThat(certificates.isArray()).isTrue();
|
||||
assertThat(certificates.size()).isGreaterThanOrEqualTo(1);
|
||||
|
||||
for (JsonNode certNode : certificates) {
|
||||
validateAgainstDefinition(certNode, "CertificateItem");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Valida resposta do endpoint /ccsp/v1/sign contra a definição SignResponse")
|
||||
void shouldValidateSignEndpointResponseAgainstSchema() throws Exception {
|
||||
String payload = """
|
||||
{
|
||||
"version": "1",
|
||||
"request_id": "%s",
|
||||
"application_id": "app-schema-test",
|
||||
"operation": "sign",
|
||||
"algorithm": "SHA256withRSA",
|
||||
"digest": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
|
||||
"certificate_hint": "cert-schema-1",
|
||||
"document_name": "documento_teste.txt",
|
||||
"nonce": "%s",
|
||||
"expires_at": "%s"
|
||||
}
|
||||
""".formatted(
|
||||
UUID.randomUUID(),
|
||||
UUID.randomUUID(),
|
||||
Instant.now().plus(60, ChronoUnit.SECONDS).toString()
|
||||
);
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(baseUrl + "/ccsp/v1/sign"))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Origin", "http://localhost:3000")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(payload))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
assertThat(response.statusCode()).isEqualTo(200);
|
||||
|
||||
JsonNode json = objectMapper.readTree(response.body());
|
||||
validateAgainstDefinition(json, "SignResponse");
|
||||
|
||||
assertThat(json.path("version").asText()).isEqualTo("1");
|
||||
assertThat(json.path("status").asText()).isEqualTo("signed");
|
||||
assertThat(json.path("signature").asText()).isNotEmpty();
|
||||
assertThat(json.path("certificate").asText()).isNotEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Valida resposta de erro contra a definição ErrorResponse e ErrorCode catalog")
|
||||
void shouldValidateErrorResponseAgainstSchema() throws Exception {
|
||||
String payload = """
|
||||
{
|
||||
"version": "1",
|
||||
"request_id": "%s",
|
||||
"application_id": "app-schema-test",
|
||||
"operation": "sign",
|
||||
"algorithm": "SHA256withRSA",
|
||||
"digest": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
|
||||
"certificate_hint": "non-existent-cert-id",
|
||||
"nonce": "%s",
|
||||
"expires_at": "%s"
|
||||
}
|
||||
""".formatted(
|
||||
UUID.randomUUID(),
|
||||
UUID.randomUUID(),
|
||||
Instant.now().plus(60, ChronoUnit.SECONDS).toString()
|
||||
);
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(baseUrl + "/ccsp/v1/sign"))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Origin", "http://localhost:3000")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(payload))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
assertThat(response.statusCode()).isGreaterThanOrEqualTo(400);
|
||||
|
||||
JsonNode json = objectMapper.readTree(response.body());
|
||||
validateAgainstDefinition(json, "ErrorResponse");
|
||||
|
||||
String errorCode = json.path("error_code").asText();
|
||||
JsonNode errorCodeEnum = schemaRoot.path("definitions").path("ErrorCode").path("enum");
|
||||
List<String> validCodes = new ArrayList<>();
|
||||
errorCodeEnum.forEach(node -> validCodes.add(node.asText()));
|
||||
|
||||
assertThat(validCodes).contains(errorCode);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Valida resposta do endpoint /ccsp/v1/seals contra a definição SealsResponse")
|
||||
void shouldValidateSealsEndpointResponseAgainstSchema() throws Exception {
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(baseUrl + "/ccsp/v1/seals"))
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
assertThat(response.statusCode()).isEqualTo(200);
|
||||
|
||||
JsonNode json = objectMapper.readTree(response.body());
|
||||
validateAgainstDefinition(json, "SealsResponse");
|
||||
}
|
||||
|
||||
private void validateAgainstDefinition(JsonNode json, String definitionName) {
|
||||
JsonNode def = schemaRoot.path("definitions").path(definitionName);
|
||||
assertThat(def.isMissingNode()).as("Definição %s deve existir no schema", definitionName).isFalse();
|
||||
|
||||
JsonNode required = def.path("required");
|
||||
if (required.isArray()) {
|
||||
for (JsonNode reqNode : required) {
|
||||
String field = reqNode.asText();
|
||||
assertThat(json.hasNonNull(field))
|
||||
.as("Campo obrigatório '%s' da definição '%s' deve estar presente e não nulo", field, definitionName)
|
||||
.isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
JsonNode properties = def.path("properties");
|
||||
if (properties.isObject()) {
|
||||
properties.fieldNames().forEachRemaining(propName -> {
|
||||
if (json.has(propName) && !json.get(propName).isNull()) {
|
||||
JsonNode propDef = properties.get(propName);
|
||||
String expectedType = propDef.path("type").asText(null);
|
||||
if (expectedType != null) {
|
||||
assertTypeMatches(json.get(propName), expectedType, propName, definitionName);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void assertTypeMatches(JsonNode valueNode, String expectedType, String propName, String defName) {
|
||||
switch (expectedType) {
|
||||
case "string" -> assertThat(valueNode.isTextual())
|
||||
.as("Propriedade '%s' em '%s' deve ser string", propName, defName).isTrue();
|
||||
case "boolean" -> assertThat(valueNode.isBoolean())
|
||||
.as("Propriedade '%s' em '%s' deve ser boolean", propName, defName).isTrue();
|
||||
case "number" -> assertThat(valueNode.isNumber())
|
||||
.as("Propriedade '%s' em '%s' deve ser number", propName, defName).isTrue();
|
||||
case "integer" -> assertThat(valueNode.isIntegralNumber())
|
||||
.as("Propriedade '%s' em '%s' deve ser integer", propName, defName).isTrue();
|
||||
case "array" -> assertThat(valueNode.isArray())
|
||||
.as("Propriedade '%s' em '%s' deve ser array", propName, defName).isTrue();
|
||||
case "object" -> assertThat(valueNode.isObject())
|
||||
.as("Propriedade '%s' em '%s' deve ser object", propName, defName).isTrue();
|
||||
default -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,22 +11,43 @@ 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 br.com.cybercore.signer.core.domain.SigningKeyMaterial;
|
||||
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.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.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.io.ByteArrayOutputStream;
|
||||
import java.math.BigInteger;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Base64;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
@@ -49,6 +70,28 @@ class CcspServerTest {
|
||||
SigningService signingService = mock(SigningService.class);
|
||||
ApplicationRegistry appRegistry = new ApplicationRegistry(tempDir.resolve("apps.json"));
|
||||
|
||||
// Gera par de chaves e certificado real em memória para suporte a PDF e XML
|
||||
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=Teste ICP-Brasil: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));
|
||||
|
||||
// Registra uma aplicação de teste
|
||||
appRegistry.register(new AuthorizedApplication(
|
||||
"app-test",
|
||||
@@ -64,8 +107,8 @@ class CcspServerTest {
|
||||
"AliasTeste",
|
||||
CertificateType.A1,
|
||||
CertificateStatus.AVAILABLE,
|
||||
"CN=Teste ICP-Brasil, C=BR",
|
||||
"CN=AC Raiz, C=BR",
|
||||
"CN=Teste ICP-Brasil:12345678900, O=CyberCore Testes, C=BR",
|
||||
"CN=Teste ICP-Brasil:12345678900, O=CyberCore Testes, C=BR",
|
||||
"12345678",
|
||||
"a1b2c3",
|
||||
"d4e5f6",
|
||||
@@ -89,6 +132,9 @@ class CcspServerTest {
|
||||
@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());
|
||||
}
|
||||
@Override public Optional<SigningKeyMaterial> getKeyMaterial(String certificateId, char[] pinOrPassword) {
|
||||
return Optional.of(new SigningKeyMaterial(kp.getPrivate(), new X509Certificate[]{cert}, null));
|
||||
}
|
||||
};
|
||||
|
||||
certManager.registerProvider(dummyProvider);
|
||||
@@ -149,7 +195,64 @@ class CcspServerTest {
|
||||
HttpResponse<String> 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");
|
||||
assertThat(resp.body()).contains("Teste ICP-Brasil");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Endpoint /certificates deve rejeitar com 401 requisições sem application_id")
|
||||
void testGetCertificatesShouldRejectWhenAppIdMissing() throws Exception {
|
||||
HttpRequest req = HttpRequest.newBuilder()
|
||||
.uri(URI.create(baseUrl + "/ccsp/v1/certificates"))
|
||||
.header("Origin", "https://meusistema.com.br")
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
HttpResponse<String> resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
assertThat(resp.statusCode()).isEqualTo(401);
|
||||
assertThat(resp.body()).contains("APPLICATION_NOT_AUTHORIZED");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Requisição OPTIONS com origem desconhecida deve ser rejeitada com 403")
|
||||
void testCorsPreflightShouldRejectUnauthorizedOrigin() throws Exception {
|
||||
HttpRequest req = HttpRequest.newBuilder()
|
||||
.uri(URI.create(baseUrl + "/ccsp/v1/sign"))
|
||||
.method("OPTIONS", HttpRequest.BodyPublishers.noBody())
|
||||
.header("Origin", "https://malicious-attacker.com")
|
||||
.build();
|
||||
|
||||
HttpResponse<Void> resp = httpClient.send(req, HttpResponse.BodyHandlers.discarding());
|
||||
assertThat(resp.statusCode()).isEqualTo(403);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Endpoint /sign deve rejeitar requisição sem nonce")
|
||||
void testSignShouldRejectWhenNonceIsMissing() throws Exception {
|
||||
byte[] docHash = MessageDigest.getInstance("SHA-256").digest("Documento".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"
|
||||
}
|
||||
""".formatted(UUID.randomUUID(), hashBase64);
|
||||
|
||||
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<String> resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
assertThat(resp.statusCode()).isEqualTo(400);
|
||||
assertThat(resp.body()).contains("REPLAY_DETECTED");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -320,4 +423,234 @@ class CcspServerTest {
|
||||
assertThat(response.body()).contains("\"document_name\":\"proposta_comercial.pdf\"");
|
||||
assertThat(response.body()).contains("\"document_type\":\"PDF\"");
|
||||
}
|
||||
|
||||
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), 12);
|
||||
cs.newLineAtOffset(50, 700);
|
||||
cs.showText("PDF de Teste CCSP - CyberCore Signer");
|
||||
cs.endText();
|
||||
}
|
||||
doc.save(baos);
|
||||
}
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Endpoint /seals deve listar temas de selos visuais disponíveis")
|
||||
void testGetSealsThemes() throws Exception {
|
||||
HttpRequest req = HttpRequest.newBuilder()
|
||||
.uri(URI.create(baseUrl + "/ccsp/v1/seals?application_id=app-test"))
|
||||
.header("Origin", "https://meusistema.com.br")
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
HttpResponse<String> resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
assertThat(resp.statusCode()).isEqualTo(200);
|
||||
assertThat(resp.body()).contains("\"status\":\"success\"");
|
||||
assertThat(resp.body()).contains("\"seals\":");
|
||||
assertThat(resp.body()).contains("icp-brasil");
|
||||
assertThat(resp.body()).contains("ICP-Brasil Institucional");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Endpoint /sign-pdf deve assinar documento PDF em Base64 e retornar signed_pdf válido")
|
||||
void testSignPdfSuccess() throws Exception {
|
||||
byte[] pdfBytes = createSamplePdf();
|
||||
String pdfBase64 = Base64.getEncoder().encodeToString(pdfBytes);
|
||||
|
||||
String jsonPayload = """
|
||||
{
|
||||
"version": "1",
|
||||
"request_id": "%s",
|
||||
"application_id": "app-test",
|
||||
"operation": "sign-pdf",
|
||||
"certificate_hint": "cert-1",
|
||||
"pdf": "%s",
|
||||
"page": 1,
|
||||
"x": 60.0,
|
||||
"y": 60.0,
|
||||
"scale": 1.0,
|
||||
"seal_theme_id": "icp-brasil",
|
||||
"reason": "Aprovado e assinado via CCSP",
|
||||
"location": "Curitiba, PR",
|
||||
"nonce": "%s"
|
||||
}
|
||||
""".formatted(UUID.randomUUID(), pdfBase64, UUID.randomUUID());
|
||||
|
||||
HttpRequest req = HttpRequest.newBuilder()
|
||||
.uri(URI.create(baseUrl + "/ccsp/v1/sign-pdf"))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Origin", "https://meusistema.com.br")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(jsonPayload))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
assertThat(resp.statusCode()).isEqualTo(200);
|
||||
assertThat(resp.body()).contains("\"status\":\"signed\"");
|
||||
assertThat(resp.body()).contains("\"signed_pdf\":");
|
||||
assertThat(resp.body()).contains("\"sha256\":");
|
||||
|
||||
// Verifica que o PDF assinado retornado pode ser carregado pelo PDFBox
|
||||
int signedPdfIdx = resp.body().indexOf("\"signed_pdf\":\"") + 14;
|
||||
int endIdx = resp.body().indexOf("\"", signedPdfIdx);
|
||||
String signedPdfBase64 = resp.body().substring(signedPdfIdx, endIdx);
|
||||
|
||||
byte[] signedBytes = Base64.getDecoder().decode(signedPdfBase64);
|
||||
try (PDDocument signedDoc = Loader.loadPDF(signedBytes)) {
|
||||
assertThat(signedDoc.getNumberOfPages()).isEqualTo(1);
|
||||
assertThat(signedDoc.getSignatureDictionaries()).isNotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Endpoint /sign-pdf com seal_custom inline deve assinar com sucesso")
|
||||
void testSignPdfCustomSealSuccess() throws Exception {
|
||||
byte[] pdfBytes = createSamplePdf();
|
||||
String pdfBase64 = Base64.getEncoder().encodeToString(pdfBytes);
|
||||
|
||||
String jsonPayload = """
|
||||
{
|
||||
"version": "1",
|
||||
"request_id": "%s",
|
||||
"application_id": "app-test",
|
||||
"operation": "sign-pdf",
|
||||
"certificate_hint": "cert-1",
|
||||
"pdf": "%s",
|
||||
"page": 1,
|
||||
"x": 40.0,
|
||||
"y": 40.0,
|
||||
"scale": 0.9,
|
||||
"seal_custom": {
|
||||
"name": "Custom Seal Test",
|
||||
"title": "SELADO DIGITALMENTE",
|
||||
"primary_color": "#2c3e50",
|
||||
"accent_color": "#18bc9c",
|
||||
"show_timestamp": true,
|
||||
"show_document_hash": true
|
||||
},
|
||||
"nonce": "%s"
|
||||
}
|
||||
""".formatted(UUID.randomUUID(), pdfBase64, UUID.randomUUID());
|
||||
|
||||
HttpRequest req = HttpRequest.newBuilder()
|
||||
.uri(URI.create(baseUrl + "/ccsp/v1/sign-pdf"))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Origin", "https://meusistema.com.br")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(jsonPayload))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
assertThat(resp.statusCode()).isEqualTo(200);
|
||||
assertThat(resp.body()).contains("\"status\":\"signed\"");
|
||||
assertThat(resp.body()).contains("\"signed_pdf\":");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Endpoint /sign-xml deve assinar XML plano com perfil XMLDSig")
|
||||
void testSignXmlSuccess() throws Exception {
|
||||
String xmlContent = "<documento id=\"doc-123\"><dados>Contrato de Teste</dados></documento>";
|
||||
|
||||
String jsonPayload = """
|
||||
{
|
||||
"version": "1",
|
||||
"request_id": "%s",
|
||||
"application_id": "app-test",
|
||||
"operation": "sign-xml",
|
||||
"certificate_hint": "cert-1",
|
||||
"xml": "%s",
|
||||
"profile": "XMLDSIG",
|
||||
"nonce": "%s"
|
||||
}
|
||||
""".formatted(UUID.randomUUID(), xmlContent.replace("\"", "\\\""), UUID.randomUUID());
|
||||
|
||||
HttpRequest req = HttpRequest.newBuilder()
|
||||
.uri(URI.create(baseUrl + "/ccsp/v1/sign-xml"))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Origin", "https://meusistema.com.br")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(jsonPayload))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
assertThat(resp.statusCode()).isEqualTo(200);
|
||||
assertThat(resp.body()).contains("\"status\":\"signed\"");
|
||||
assertThat(resp.body()).contains("\"signed_xml\":");
|
||||
assertThat(resp.body()).contains("SignatureValue");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Endpoint /sign-xml deve assinar XML em Base64 com perfil XAdES-BES")
|
||||
void testSignXmlBase64XadesBes() throws Exception {
|
||||
String xmlContent = "<pedido id=\"ped-999\"><valor>150.00</valor></pedido>";
|
||||
String xmlBase64 = Base64.getEncoder().encodeToString(xmlContent.getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
String jsonPayload = """
|
||||
{
|
||||
"version": "1",
|
||||
"request_id": "%s",
|
||||
"application_id": "app-test",
|
||||
"operation": "sign-xml",
|
||||
"certificate_hint": "cert-1",
|
||||
"xml_base64": "%s",
|
||||
"profile": "XADES_BES",
|
||||
"nonce": "%s"
|
||||
}
|
||||
""".formatted(UUID.randomUUID(), xmlBase64, UUID.randomUUID());
|
||||
|
||||
HttpRequest req = HttpRequest.newBuilder()
|
||||
.uri(URI.create(baseUrl + "/ccsp/v1/sign-xml"))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Origin", "https://meusistema.com.br")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(jsonPayload))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
assertThat(resp.statusCode()).isEqualTo(200);
|
||||
assertThat(resp.body()).contains("\"status\":\"signed\"");
|
||||
assertThat(resp.body()).contains("\"signed_xml\":");
|
||||
assertThat(resp.body()).contains("QualifyingProperties");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Requisição com cabeçalho Host não-loopback (DNS Rebinding) deve ser rejeitada com 403")
|
||||
void testDnsRebindingHostRejection() throws Exception {
|
||||
int port = server.getPort();
|
||||
try (java.net.Socket socket = new java.net.Socket("127.0.0.1", port);
|
||||
java.io.OutputStream out = socket.getOutputStream();
|
||||
java.io.BufferedReader in = new java.io.BufferedReader(new java.io.InputStreamReader(socket.getInputStream()))) {
|
||||
|
||||
String rawRequest = "GET /ccsp/v1/status HTTP/1.1\r\n" +
|
||||
"Host: evil.attacker.com\r\n" +
|
||||
"Connection: close\r\n\r\n";
|
||||
out.write(rawRequest.getBytes(StandardCharsets.UTF_8));
|
||||
out.flush();
|
||||
|
||||
String statusLine = in.readLine();
|
||||
assertThat(statusLine).contains("403");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Requisição com cabeçalho Host loopback válido deve ser aceita")
|
||||
void testValidLoopbackHostAcceptance() throws Exception {
|
||||
int port = server.getPort();
|
||||
try (java.net.Socket socket = new java.net.Socket("127.0.0.1", port);
|
||||
java.io.OutputStream out = socket.getOutputStream();
|
||||
java.io.BufferedReader in = new java.io.BufferedReader(new java.io.InputStreamReader(socket.getInputStream()))) {
|
||||
|
||||
String rawRequest = "GET /ccsp/v1/status HTTP/1.1\r\n" +
|
||||
"Host: 127.0.0.1:" + port + "\r\n" +
|
||||
"Connection: close\r\n\r\n";
|
||||
out.write(rawRequest.getBytes(StandardCharsets.UTF_8));
|
||||
out.flush();
|
||||
|
||||
String statusLine = in.readLine();
|
||||
assertThat(statusLine).contains("200");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,4 +63,81 @@ class CertificateManagerTest {
|
||||
assertThat(byCnpj).isPresent();
|
||||
assertThat(byCnpj.get().id()).isEqualTo("cert-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Deve desativar provider PKCS#11 quando chave de diagnóstico estiver ativa")
|
||||
void shouldRespectDiagnosticsNoPkcs11() {
|
||||
System.setProperty("cybercore.diag.no-pkcs11", "true");
|
||||
try {
|
||||
CertificateManager manager = CertificateManager.createDefault();
|
||||
assertThat(manager.getProviders())
|
||||
.extracting(CertificateProvider::getProviderId)
|
||||
.doesNotContain("provider-pkcs11")
|
||||
.contains("provider-a1");
|
||||
} finally {
|
||||
System.clearProperty("cybercore.diag.no-pkcs11");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Deve desativar provider Windows Certificate Store quando chave de diagnóstico estiver ativa")
|
||||
void shouldRespectDiagnosticsNoWinCert() {
|
||||
System.setProperty("cybercore.diag.no-wincert", "true");
|
||||
try {
|
||||
CertificateManager manager = CertificateManager.createDefault();
|
||||
assertThat(manager.getProviders())
|
||||
.extracting(CertificateProvider::getProviderId)
|
||||
.doesNotContain("provider-win-store")
|
||||
.contains("provider-a1");
|
||||
} finally {
|
||||
System.clearProperty("cybercore.diag.no-wincert");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Deve reutilizar cache sob demanda dentro do TTL e invalidar sob forceRefresh ou hardware events")
|
||||
void shouldReuseCacheWithinTtlAndInvalidateOnHardwareEvents() throws Exception {
|
||||
CertificateManager manager = new CertificateManager(5_000L); // 5 segundos TTL
|
||||
|
||||
CertificateProvider provider = mock(CertificateProvider.class);
|
||||
when(provider.getProviderId()).thenReturn("mock-prov");
|
||||
when(provider.isAvailable()).thenReturn(true);
|
||||
|
||||
ManagedCertificate cert = new ManagedCertificate(
|
||||
"c1", "Mock Cert", CertificateType.A1, CertificateStatus.AVAILABLE,
|
||||
"CN=Mock", "CN=AC", "1", "s1", "s2",
|
||||
Instant.now().minus(1, ChronoUnit.DAYS), Instant.now().plus(30, ChronoUnit.DAYS),
|
||||
"RSA", 2048, "Mock", null, null, "mock-prov", null
|
||||
);
|
||||
when(provider.listCertificates()).thenReturn(List.of(cert));
|
||||
|
||||
manager.registerProvider(provider);
|
||||
|
||||
// 1ª chamada: consulta o provider
|
||||
List<ManagedCertificate> first = manager.discoverCertificates();
|
||||
assertThat(first).hasSize(1);
|
||||
org.mockito.Mockito.verify(provider, org.mockito.Mockito.times(1)).listCertificates();
|
||||
|
||||
// 2ª chamada: dentro do TTL de 5s, não deve chamar o provider novamente
|
||||
List<ManagedCertificate> second = manager.discoverCertificates();
|
||||
assertThat(second).isSameAs(first);
|
||||
org.mockito.Mockito.verify(provider, org.mockito.Mockito.times(1)).listCertificates();
|
||||
|
||||
// 3ª chamada com forceRefresh=true: deve chamar o provider
|
||||
List<ManagedCertificate> third = manager.discoverCertificates(true);
|
||||
assertThat(third).hasSize(1);
|
||||
org.mockito.Mockito.verify(provider, org.mockito.Mockito.times(2)).listCertificates();
|
||||
|
||||
// 4ª chamada: simula evento de hardware (inserção de cartão)
|
||||
manager.onCardInserted("Terminal-USB-1");
|
||||
List<ManagedCertificate> fourth = manager.discoverCertificates();
|
||||
assertThat(fourth).hasSize(1);
|
||||
org.mockito.Mockito.verify(provider, org.mockito.Mockito.times(3)).listCertificates();
|
||||
|
||||
// 5ª chamada: simula evento de hardware (remoção de cartão)
|
||||
manager.onCardRemoved("Terminal-USB-1");
|
||||
List<ManagedCertificate> fifth = manager.discoverCertificates();
|
||||
assertThat(fifth).hasSize(1);
|
||||
org.mockito.Mockito.verify(provider, org.mockito.Mockito.times(4)).listCertificates();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
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 org.bouncycastle.asn1.x500.X500Name;
|
||||
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;
|
||||
import org.bouncycastle.cert.X509CertificateHolder;
|
||||
import org.bouncycastle.cert.X509v3CertificateBuilder;
|
||||
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
|
||||
import org.bouncycastle.operator.ContentSigner;
|
||||
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
|
||||
import org.junit.jupiter.api.Assumptions;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileWriter;
|
||||
import java.math.BigInteger;
|
||||
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.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 java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@DisplayName("Integração Real PKCS#11 / Hardware via SoftHSM2")
|
||||
class SoftHsmPkcs11IntegrationTest {
|
||||
|
||||
private static final String[] CANDIDATE_SOFTHSM_LIBS = {
|
||||
"/usr/lib/softhsm/libsofthsm2.so",
|
||||
"/usr/lib/x86_64-linux-gnu/softhsm/libsofthsm2.so",
|
||||
"/usr/local/lib/softhsm/libsofthsm2.so",
|
||||
"C:/SoftHSM2/lib/softhsm2.dll"
|
||||
};
|
||||
|
||||
private static String foundLibrary;
|
||||
|
||||
@BeforeAll
|
||||
static void checkSoftHsmAvailability() {
|
||||
for (String candidate : CANDIDATE_SOFTHSM_LIBS) {
|
||||
File f = new File(candidate);
|
||||
if (f.exists() && f.canRead()) {
|
||||
foundLibrary = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Deve inicializar token PKCS#11, listar certificados e assinar digest via SunPKCS11")
|
||||
void shouldSignDigestUsingRealPkcs11HardwareToken(@TempDir Path tempDir) throws Exception {
|
||||
Assumptions.assumeTrue(foundLibrary != null,
|
||||
"SoftHSM2 não encontrado no sistema operacional. Teste ignorado (será executado no CI Linux).");
|
||||
|
||||
// 1. Configura diretório isolado de tokens do SoftHSM2
|
||||
Path tokensDir = tempDir.resolve("tokens");
|
||||
File tokensFolder = tokensDir.toFile();
|
||||
boolean created = tokensFolder.mkdirs();
|
||||
assertThat(created || tokensFolder.exists()).isTrue();
|
||||
|
||||
Path confFile = tempDir.resolve("softhsm2.conf");
|
||||
try (FileWriter writer = new FileWriter(confFile.toFile(), StandardCharsets.UTF_8)) {
|
||||
writer.write("directories.tokendir = " + tokensFolder.getAbsolutePath() + "\n");
|
||||
writer.write("objectstore.backend = file\n");
|
||||
writer.write("log.level = ERROR\n");
|
||||
}
|
||||
|
||||
// 2. Inicializa o token via softhsm2-util se disponível
|
||||
ProcessBuilder pb = new ProcessBuilder(
|
||||
"softhsm2-util",
|
||||
"--init-token",
|
||||
"--slot", "0",
|
||||
"--label", "CyberCore-CI-Token",
|
||||
"--so-pin", "87654321",
|
||||
"--pin", "1234"
|
||||
);
|
||||
pb.environment().put("SOFTHSM2_CONF", confFile.toAbsolutePath().toString());
|
||||
pb.redirectErrorStream(true);
|
||||
|
||||
Process process = pb.start();
|
||||
int exitCode = process.waitFor();
|
||||
Assumptions.assumeTrue(exitCode == 0, "softhsm2-util falhou ou não está no PATH. Teste ignorado.");
|
||||
|
||||
// 3. Gera par de chaves RSA e certificado para importar no token via KeyStore PKCS11
|
||||
KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
|
||||
kpg.initialize(2048);
|
||||
KeyPair keyPair = kpg.generateKeyPair();
|
||||
|
||||
X500Name subject = new X500Name("CN=CERTIFICADO TESTE PKCS11, O=ICP-Brasil Teste, C=BR");
|
||||
BigInteger serial = BigInteger.valueOf(System.currentTimeMillis());
|
||||
Date notBefore = Date.from(Instant.now().minus(1, ChronoUnit.DAYS));
|
||||
Date notAfter = Date.from(Instant.now().plus(365, ChronoUnit.DAYS));
|
||||
|
||||
SubjectPublicKeyInfo subPubKeyInfo = SubjectPublicKeyInfo.getInstance(keyPair.getPublic().getEncoded());
|
||||
X509v3CertificateBuilder certBuilder = new X509v3CertificateBuilder(
|
||||
subject, serial, notBefore, notAfter, subject, subPubKeyInfo
|
||||
);
|
||||
ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").build(keyPair.getPrivate());
|
||||
X509CertificateHolder holder = certBuilder.build(signer);
|
||||
X509Certificate testCert = new JcaX509CertificateConverter().getCertificate(holder);
|
||||
|
||||
// 4. Inicializa o Pkcs11CertificateProvider
|
||||
Pkcs11CertificateProvider provider = new Pkcs11CertificateProvider();
|
||||
provider.initializeWithPin(foundLibrary, 0, "1234".toCharArray());
|
||||
|
||||
// Importa a chave gerada para o token caso ainda não haja chaves
|
||||
KeyStore ks = KeyStore.getInstance("PKCS11", java.security.Security.getProvider("SunPKCS11"));
|
||||
try {
|
||||
ks.setKeyEntry("teste-alias", keyPair.getPrivate(), "1234".toCharArray(), new Certificate[]{testCert});
|
||||
} catch (Exception e) {
|
||||
// Alguns drivers PKCS11 não suportam setKeyEntry diretamente sem C_CreateObject
|
||||
}
|
||||
|
||||
List<ManagedCertificate> certs = provider.listCertificates();
|
||||
assertThat(provider.isAvailable()).isTrue();
|
||||
|
||||
// 5. Teste de assinatura de digest via SigningContext
|
||||
byte[] payload = "CyberCore Systems Assinatura Digital PKCS#11".getBytes(StandardCharsets.UTF_8);
|
||||
byte[] digest = MessageDigest.getInstance("SHA-256").digest(payload);
|
||||
|
||||
SigningContext context = new SigningContext(
|
||||
UUID.randomUUID().toString(),
|
||||
"app-test-id",
|
||||
digest,
|
||||
"SHA256withRSA",
|
||||
certs.isEmpty() ? null : certs.get(0).id(),
|
||||
Instant.now()
|
||||
);
|
||||
|
||||
if (!certs.isEmpty()) {
|
||||
SigningResult result = provider.sign(context);
|
||||
assertThat(result).isNotNull();
|
||||
assertThat(result.signatureBytes()).isNotEmpty();
|
||||
|
||||
// Validação matemática da assinatura com a chave pública
|
||||
Signature jcaSig = Signature.getInstance("SHA256withRSA");
|
||||
jcaSig.initVerify(testCert.getPublicKey());
|
||||
jcaSig.update(digest);
|
||||
assertThat(jcaSig.verify(result.signatureBytes())).isTrue();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,4 +44,29 @@ class ApplicationRegistryTest {
|
||||
reloaded.revoke("app-123");
|
||||
assertThat(reloaded.findById("app-123")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Deve descartar aplicações e alertar se o arquivo json for adulterado sem HMAC correspondente")
|
||||
void testTamperDetection(@TempDir Path tempDir) throws Exception {
|
||||
Path jsonPath = tempDir.resolve("authorized-apps.json");
|
||||
ApplicationRegistry registry = new ApplicationRegistry(jsonPath);
|
||||
|
||||
AuthorizedApplication app = new AuthorizedApplication(
|
||||
"app-1",
|
||||
"Legit App",
|
||||
Set.of("https://legit.com"),
|
||||
true,
|
||||
Instant.now()
|
||||
);
|
||||
registry.register(app);
|
||||
|
||||
// Adultera o arquivo JSON diretamente no disco simulando malware local
|
||||
java.nio.file.Files.writeString(jsonPath, "[{\"applicationId\":\"malicious\",\"name\":\"Malware\",\"allowedOrigins\":[\"*\"],\"active\":true}]");
|
||||
|
||||
// Novo registry tenta carregar
|
||||
ApplicationRegistry tamperedRegistry = new ApplicationRegistry(jsonPath);
|
||||
assertThat(tamperedRegistry.findById("malicious")).isEmpty();
|
||||
assertThat(tamperedRegistry.findById("app-1")).isEmpty();
|
||||
assertThat(tamperedRegistry.listAll()).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
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.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@DisplayName("Testes de Integridade Criptográfica (HmacIntegrityManager)")
|
||||
class HmacIntegrityManagerTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("Deve gerar HMAC-SHA256 válido e verificar correspondência em tempo constante")
|
||||
void testComputeAndVerifyHmac(@TempDir Path tempDir) {
|
||||
Path keyPath = tempDir.resolve(".vault_key");
|
||||
HmacIntegrityManager manager = new HmacIntegrityManager(keyPath);
|
||||
|
||||
byte[] payload = "{\"test\":\"cybercore\"}".getBytes(StandardCharsets.UTF_8);
|
||||
String hmac = manager.computeHmacHex(payload);
|
||||
|
||||
assertThat(hmac).isNotEmpty().hasSize(64); // 256 bits = 64 hex chars
|
||||
assertThat(manager.verifyHmac(payload, hmac)).isTrue();
|
||||
assertThat(manager.verifyHmac("tampered".getBytes(StandardCharsets.UTF_8), hmac)).isFalse();
|
||||
assertThat(manager.verifyHmac(payload, "invalidhmac")).isFalse();
|
||||
assertThat(manager.verifyHmac(null, hmac)).isFalse();
|
||||
assertThat(manager.verifyHmac(payload, null)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Deve reutilizar chave persistida em disco")
|
||||
void testKeyPersistence(@TempDir Path tempDir) {
|
||||
Path keyPath = tempDir.resolve(".vault_key");
|
||||
HmacIntegrityManager manager1 = new HmacIntegrityManager(keyPath);
|
||||
|
||||
byte[] payload = "data-to-protect".getBytes(StandardCharsets.UTF_8);
|
||||
String hmac1 = manager1.computeHmacHex(payload);
|
||||
|
||||
// Novo manager apontando para a mesma chave persistida
|
||||
HmacIntegrityManager manager2 = new HmacIntegrityManager(keyPath);
|
||||
String hmac2 = manager2.computeHmacHex(payload);
|
||||
|
||||
assertThat(hmac1).isEqualTo(hmac2);
|
||||
assertThat(manager2.verifyHmac(payload, hmac1)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Deve restringir permissões de arquivo e diretório")
|
||||
void testRestrictPermissions(@TempDir Path tempDir) throws Exception {
|
||||
Path testFile = tempDir.resolve("protected.txt");
|
||||
Files.writeString(testFile, "sensitive-data");
|
||||
|
||||
HmacIntegrityManager.restrictPermissions(testFile);
|
||||
assertThat(Files.isReadable(testFile)).isTrue();
|
||||
assertThat(Files.isWritable(testFile)).isTrue();
|
||||
}
|
||||
}
|
||||
@@ -28,4 +28,84 @@ class NonceManagerTest {
|
||||
assertThat(manager.registerNonce(null)).isFalse();
|
||||
assertThat(manager.registerNonce(" ")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Deve permitir apenas 1 registro concorrente simultâneo para o mesmo nonce")
|
||||
void shouldBeThreadSafeUnderHighConcurrency() throws Exception {
|
||||
NonceManager manager = new NonceManager();
|
||||
String identicalNonce = "shared-race-nonce-" + java.util.UUID.randomUUID();
|
||||
int threads = 30;
|
||||
|
||||
java.util.concurrent.ExecutorService executor = java.util.concurrent.Executors.newFixedThreadPool(threads);
|
||||
java.util.concurrent.CountDownLatch startLatch = new java.util.concurrent.CountDownLatch(1);
|
||||
java.util.concurrent.atomic.AtomicInteger successes = new java.util.concurrent.atomic.AtomicInteger(0);
|
||||
java.util.concurrent.atomic.AtomicInteger rejections = new java.util.concurrent.atomic.AtomicInteger(0);
|
||||
|
||||
java.util.List<java.util.concurrent.Future<?>> futures = new java.util.ArrayList<>();
|
||||
for (int i = 0; i < threads; i++) {
|
||||
futures.add(executor.submit(() -> {
|
||||
try {
|
||||
startLatch.await();
|
||||
boolean registered = manager.registerNonce(identicalNonce);
|
||||
if (registered) {
|
||||
successes.incrementAndGet();
|
||||
} else {
|
||||
rejections.incrementAndGet();
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
startLatch.countDown();
|
||||
for (java.util.concurrent.Future<?> f : futures) {
|
||||
f.get();
|
||||
}
|
||||
executor.shutdown();
|
||||
|
||||
assertThat(successes.get()).isEqualTo(1);
|
||||
assertThat(rejections.get()).isEqualTo(threads - 1);
|
||||
assertThat(manager.activeNonceCount()).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Deve validar timestamp com tolerância a clock skew (passado recente e pequeno futuro)")
|
||||
void shouldValidateTimestampWithClockSkew() {
|
||||
NonceManager manager = new NonceManager(Duration.ofSeconds(60));
|
||||
java.time.Instant now = java.time.Instant.now();
|
||||
|
||||
// Timestamp atual é válido
|
||||
assertThat(manager.isTimestampValid(now)).isTrue();
|
||||
|
||||
// 10 segundos no passado (dentro da janela de 60s) é válido
|
||||
assertThat(manager.isTimestampValid(now.minusSeconds(10))).isTrue();
|
||||
|
||||
// 20 segundos no futuro (dentro do clock skew de 30s) é tolerado
|
||||
assertThat(manager.isTimestampValid(now.plusSeconds(20))).isTrue();
|
||||
|
||||
// 100 segundos no passado (fora de 60s + 30s = 90s) é rejeitado
|
||||
assertThat(manager.isTimestampValid(now.minusSeconds(100))).isFalse();
|
||||
|
||||
// 60 segundos no futuro (além do skew de 30s) é rejeitado
|
||||
assertThat(manager.isTimestampValid(now.plusSeconds(60))).isFalse();
|
||||
|
||||
// Nulo é rejeitado
|
||||
assertThat(manager.isTimestampValid(null)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Deve limpar nonces expirados além da janela configurada")
|
||||
void shouldCleanupExpiredNonces() throws Exception {
|
||||
NonceManager manager = new NonceManager(Duration.ofMillis(50));
|
||||
manager.registerNonce("temporary-nonce");
|
||||
assertThat(manager.activeNonceCount()).isEqualTo(1);
|
||||
|
||||
Thread.sleep(70);
|
||||
manager.cleanupExpired();
|
||||
assertThat(manager.activeNonceCount()).isEqualTo(0);
|
||||
|
||||
// Após expirar e ser limpo, pode ser aceito novamente
|
||||
assertThat(manager.registerNonce("temporary-nonce")).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,4 +58,17 @@ class PinSessionManagerTest {
|
||||
assertThat(manager.hasPin("cert-a")).isFalse();
|
||||
assertThat(manager.hasPin("cert-b")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Deve isolar PINs entre diferentes aplicações para o mesmo certificado")
|
||||
void shouldIsolatePinByApplicationId() {
|
||||
manager.savePin("app-1", "cert-x", "pin-app-1".toCharArray());
|
||||
|
||||
assertThat(manager.hasPin("app-1", "cert-x")).isTrue();
|
||||
assertThat(manager.getPin("app-1", "cert-x")).isEqualTo("pin-app-1".toCharArray());
|
||||
|
||||
// Aplicação 2 não deve ter acesso ao PIN da Aplicação 1
|
||||
assertThat(manager.hasPin("app-2", "cert-x")).isFalse();
|
||||
assertThat(manager.getPin("app-2", "cert-x")).isNull();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package br.com.cybercore.signer.core.security;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
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;
|
||||
|
||||
class SecurePinStorageTest {
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
private SecurePinStorage storage;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
storage = new SecurePinStorage(tempDir);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
storage.clearAll();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldSaveAndRetrievePinCorrectly() {
|
||||
String certId = "cert-test-123";
|
||||
char[] pin = "123456".toCharArray();
|
||||
|
||||
storage.savePin(certId, pin);
|
||||
|
||||
assertThat(storage.hasPin(certId)).isTrue();
|
||||
assertThat(storage.getPin(certId)).isEqualTo(pin);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPersistAndReloadFromDisk() {
|
||||
String certId = "token-a3-999";
|
||||
char[] pin = "senhaForte987".toCharArray();
|
||||
|
||||
storage.savePin(certId, pin);
|
||||
|
||||
// Cria nova instância apontando para o mesmo diretório
|
||||
SecurePinStorage reloaded = new SecurePinStorage(tempDir);
|
||||
|
||||
assertThat(reloaded.hasPin(certId)).isTrue();
|
||||
assertThat(reloaded.getPin(certId)).isEqualTo(pin);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRemoveSpecificPin() {
|
||||
storage.savePin("cert-1", "1111".toCharArray());
|
||||
storage.savePin("cert-2", "2222".toCharArray());
|
||||
|
||||
storage.removePin("cert-1");
|
||||
|
||||
assertThat(storage.hasPin("cert-1")).isFalse();
|
||||
assertThat(storage.getPin("cert-1")).isNull();
|
||||
assertThat(storage.hasPin("cert-2")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldClearAllPins() {
|
||||
storage.savePin("cert-1", "1111".toCharArray());
|
||||
storage.savePin("cert-2", "2222".toCharArray());
|
||||
|
||||
storage.clearAll();
|
||||
|
||||
assertThat(storage.hasPin("cert-1")).isFalse();
|
||||
assertThat(storage.hasPin("cert-2")).isFalse();
|
||||
assertThat(storage.getStoredCount()).isEqualTo(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package br.com.cybercore.signer.core.validation;
|
||||
|
||||
import br.com.cybercore.signer.core.protocol.CcspErrorCode;
|
||||
import br.com.cybercore.signer.core.provider.CertificateProviderException;
|
||||
import org.bouncycastle.asn1.x500.X500Name;
|
||||
import org.bouncycastle.asn1.x509.BasicConstraints;
|
||||
import org.bouncycastle.asn1.x509.Extension;
|
||||
import org.bouncycastle.asn1.x509.KeyUsage;
|
||||
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;
|
||||
import org.bouncycastle.cert.X509CertificateHolder;
|
||||
import org.bouncycastle.cert.X509v3CertificateBuilder;
|
||||
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
|
||||
import org.bouncycastle.operator.ContentSigner;
|
||||
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
@DisplayName("Testes de Validação de Cadeia de Certificados X.509")
|
||||
class CertificateChainValidatorTest {
|
||||
|
||||
private static KeyPair rootKeyPair;
|
||||
private static KeyPair intermediateKeyPair;
|
||||
private static KeyPair leafKeyPair;
|
||||
|
||||
private static X509Certificate rootCert;
|
||||
private static X509Certificate intermediateCert;
|
||||
private static X509Certificate leafCert;
|
||||
|
||||
@BeforeAll
|
||||
static void generateTestPki() throws Exception {
|
||||
KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
|
||||
kpg.initialize(2048);
|
||||
rootKeyPair = kpg.generateKeyPair();
|
||||
intermediateKeyPair = kpg.generateKeyPair();
|
||||
leafKeyPair = kpg.generateKeyPair();
|
||||
|
||||
Date notBefore = Date.from(Instant.now().minus(10, ChronoUnit.DAYS));
|
||||
Date notAfter = Date.from(Instant.now().plus(365, ChronoUnit.DAYS));
|
||||
|
||||
// 1. AC Raiz (Autoassinada, CA=true)
|
||||
X500Name rootName = new X500Name("CN=AC Raiz Teste, O=ICP-Brasil Teste, C=BR");
|
||||
X509v3CertificateBuilder rootBuilder = new X509v3CertificateBuilder(
|
||||
rootName, BigInteger.valueOf(1), notBefore, notAfter, rootName,
|
||||
SubjectPublicKeyInfo.getInstance(rootKeyPair.getPublic().getEncoded())
|
||||
);
|
||||
rootBuilder.addExtension(Extension.basicConstraints, true, new BasicConstraints(true));
|
||||
rootBuilder.addExtension(Extension.keyUsage, true, new KeyUsage(KeyUsage.keyCertSign | KeyUsage.cRLSign));
|
||||
ContentSigner rootSigner = new JcaContentSignerBuilder("SHA256withRSA").build(rootKeyPair.getPrivate());
|
||||
rootCert = new JcaX509CertificateConverter().getCertificate(rootBuilder.build(rootSigner));
|
||||
|
||||
// 2. AC Intermediária (Assinada pela Raiz, CA=true)
|
||||
X500Name intermediateName = new X500Name("CN=AC Intermediaria Teste, O=ICP-Brasil Teste, C=BR");
|
||||
X509v3CertificateBuilder intermediateBuilder = new X509v3CertificateBuilder(
|
||||
rootName, BigInteger.valueOf(2), notBefore, notAfter, intermediateName,
|
||||
SubjectPublicKeyInfo.getInstance(intermediateKeyPair.getPublic().getEncoded())
|
||||
);
|
||||
intermediateBuilder.addExtension(Extension.basicConstraints, true, new BasicConstraints(true));
|
||||
intermediateBuilder.addExtension(Extension.keyUsage, true, new KeyUsage(KeyUsage.keyCertSign | KeyUsage.cRLSign));
|
||||
intermediateCert = new JcaX509CertificateConverter().getCertificate(intermediateBuilder.build(rootSigner));
|
||||
|
||||
// 3. Certificado Folha (Assinado pela Intermediária, CA=false)
|
||||
X500Name leafName = new X500Name("CN=JOAO DA SILVA:12345678901, O=ICP-Brasil Teste, C=BR");
|
||||
X509v3CertificateBuilder leafBuilder = new X509v3CertificateBuilder(
|
||||
intermediateName, BigInteger.valueOf(3), notBefore, notAfter, leafName,
|
||||
SubjectPublicKeyInfo.getInstance(leafKeyPair.getPublic().getEncoded())
|
||||
);
|
||||
leafBuilder.addExtension(Extension.basicConstraints, true, new BasicConstraints(false));
|
||||
leafBuilder.addExtension(Extension.keyUsage, true, new KeyUsage(KeyUsage.digitalSignature | KeyUsage.nonRepudiation));
|
||||
ContentSigner intermediateSigner = new JcaContentSignerBuilder("SHA256withRSA").build(intermediateKeyPair.getPrivate());
|
||||
leafCert = new JcaX509CertificateConverter().getCertificate(leafBuilder.build(intermediateSigner));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Cadeia Válida Completa: deve validar com sucesso contra raízes confiáveis")
|
||||
void shouldValidateCompliantChainSuccessfully() {
|
||||
List<X509Certificate> chain = List.of(leafCert, intermediateCert, rootCert);
|
||||
Set<X509Certificate> trustedRoots = Set.of(rootCert);
|
||||
|
||||
assertThatCode(() -> CertificateChainValidator.validateChain(chain, trustedRoots))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Certificado Expirado: deve lançar CERTIFICATE_EXPIRED")
|
||||
void shouldThrowCertificateExpiredWhenReferenceDateIsBeyondValidity() {
|
||||
List<X509Certificate> chain = List.of(leafCert, intermediateCert, rootCert);
|
||||
Date futureDate = Date.from(Instant.now().plus(400, ChronoUnit.DAYS));
|
||||
|
||||
assertThatThrownBy(() -> CertificateChainValidator.validateChain(chain, Set.of(rootCert), futureDate))
|
||||
.isInstanceOf(CertificateProviderException.class)
|
||||
.satisfies(ex -> {
|
||||
CertificateProviderException cpe = (CertificateProviderException) ex;
|
||||
assertThat(cpe.getErrorCode()).isEqualTo(CcspErrorCode.CERTIFICATE_EXPIRED);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Assinatura Corrompida: deve lançar CERTIFICATE_INVALID se a assinatura de um elo falhar")
|
||||
void shouldThrowCertificateInvalidWhenSignatureIsCorrupted() throws Exception {
|
||||
// Gera um cert folha assinado por outra chave que não a intermediária
|
||||
KeyPair fakeKeyPair = KeyPairGenerator.getInstance("RSA").generateKeyPair();
|
||||
ContentSigner fakeSigner = new JcaContentSignerBuilder("SHA256withRSA").build(fakeKeyPair.getPrivate());
|
||||
|
||||
X500Name intermediateName = new X500Name("CN=AC Intermediaria Teste, O=ICP-Brasil Teste, C=BR");
|
||||
X500Name leafName = new X500Name("CN=ADULTERADO, C=BR");
|
||||
X509v3CertificateBuilder fakeBuilder = new X509v3CertificateBuilder(
|
||||
intermediateName, BigInteger.valueOf(99),
|
||||
Date.from(Instant.now().minus(1, ChronoUnit.DAYS)),
|
||||
Date.from(Instant.now().plus(30, ChronoUnit.DAYS)),
|
||||
leafName,
|
||||
SubjectPublicKeyInfo.getInstance(leafKeyPair.getPublic().getEncoded())
|
||||
);
|
||||
X509Certificate corruptedLeaf = new JcaX509CertificateConverter().getCertificate(fakeBuilder.build(fakeSigner));
|
||||
|
||||
List<X509Certificate> corruptedChain = List.of(corruptedLeaf, intermediateCert, rootCert);
|
||||
|
||||
assertThatThrownBy(() -> CertificateChainValidator.validateChain(corruptedChain, Set.of(rootCert)))
|
||||
.isInstanceOf(CertificateProviderException.class)
|
||||
.satisfies(ex -> {
|
||||
CertificateProviderException cpe = (CertificateProviderException) ex;
|
||||
assertThat(cpe.getErrorCode()).isEqualTo(CcspErrorCode.CERTIFICATE_INVALID);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Cadeia Incompleta / Desconexa: deve lançar CERTIFICATE_INVALID")
|
||||
void shouldThrowCertificateInvalidWhenChainIsBroken() {
|
||||
// Passa folha diretamente apontando para raiz sem o intermediário
|
||||
List<X509Certificate> brokenChain = List.of(leafCert, rootCert);
|
||||
|
||||
assertThatThrownBy(() -> CertificateChainValidator.validateChain(brokenChain, Set.of(rootCert)))
|
||||
.isInstanceOf(CertificateProviderException.class)
|
||||
.satisfies(ex -> {
|
||||
CertificateProviderException cpe = (CertificateProviderException) ex;
|
||||
assertThat(cpe.getErrorCode()).isEqualTo(CcspErrorCode.CERTIFICATE_INVALID);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Raiz Não Confiável: deve lançar CERTIFICATE_INVALID quando a raiz não está no TrustStore")
|
||||
void shouldThrowCertificateInvalidWhenRootIsNotTrusted() throws Exception {
|
||||
KeyPair otherRootKey = KeyPairGenerator.getInstance("RSA").generateKeyPair();
|
||||
X500Name otherRootName = new X500Name("CN=Outra Raiz Estranha, C=BR");
|
||||
X509v3CertificateBuilder otherBuilder = new X509v3CertificateBuilder(
|
||||
otherRootName, BigInteger.ONE,
|
||||
Date.from(Instant.now().minus(1, ChronoUnit.DAYS)),
|
||||
Date.from(Instant.now().plus(30, ChronoUnit.DAYS)),
|
||||
otherRootName,
|
||||
SubjectPublicKeyInfo.getInstance(otherRootKey.getPublic().getEncoded())
|
||||
);
|
||||
otherBuilder.addExtension(Extension.basicConstraints, true, new BasicConstraints(true));
|
||||
ContentSigner otherSigner = new JcaContentSignerBuilder("SHA256withRSA").build(otherRootKey.getPrivate());
|
||||
X509Certificate untrustedRoot = new JcaX509CertificateConverter().getCertificate(otherBuilder.build(otherSigner));
|
||||
|
||||
List<X509Certificate> chain = List.of(leafCert, intermediateCert, rootCert);
|
||||
Set<X509Certificate> trustedRoots = Set.of(untrustedRoot); // Não inclui a rootCert da cadeia
|
||||
|
||||
assertThatThrownBy(() -> CertificateChainValidator.validateChain(chain, trustedRoots))
|
||||
.isInstanceOf(CertificateProviderException.class)
|
||||
.satisfies(ex -> {
|
||||
CertificateProviderException cpe = (CertificateProviderException) ex;
|
||||
assertThat(cpe.getErrorCode()).isEqualTo(CcspErrorCode.CERTIFICATE_INVALID);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Cadeia Vazia: deve lançar CERTIFICATE_NOT_FOUND")
|
||||
void shouldThrowCertificateNotFoundForEmptyChain() {
|
||||
assertThatThrownBy(() -> CertificateChainValidator.validateChain(List.of(), null))
|
||||
.isInstanceOf(CertificateProviderException.class)
|
||||
.satisfies(ex -> {
|
||||
CertificateProviderException cpe = (CertificateProviderException) ex;
|
||||
assertThat(cpe.getErrorCode()).isEqualTo(CcspErrorCode.CERTIFICATE_NOT_FOUND);
|
||||
});
|
||||
}
|
||||
}
|
||||