35 Commits
Author SHA1 Message Date
Claudecio Martins 2c1d4f359e docs: harmonize documentation cross-references and validation status
CI Pipeline / Build & Verify (Windows) (push) Canceled after 0s
CI Pipeline / Build & Verify (Linux x86_64) (push) Canceled after 0s
2026-10-20 16:48:17 -03:00
Claudecio Martins 8d0e587114 chore(sdk): prepare package manifest for gitea npm registry and document release flow 2026-10-20 16:45:03 -03:00
Claudecio Martins b19d09a236 docs(sdk): add comprehensive integration examples in sdk/examples 2026-10-20 16:43:46 -03:00
Claudecio Martins 50b5feced5 docs(browser): document https to localhost compatibility and private network access 2026-10-20 16:42:40 -03:00
Claudecio Martins 9dcbac61ec test(ccsp): add bidirectional cross-validation tests against ccsp-v1 schema 2026-10-20 16:42:06 -03:00
Claudecio Martins 957f9c9abd feat(sdk): add typescript types actionable errors and mock server tests 2026-10-20 16:35:29 -03:00
Claudecio Martins e9f90b42cb docs(ccsp): formalize ccsp v1 json schema error catalog and versioning policy 2026-10-20 16:28:33 -03:00
Claudecio Martins 92d4ea3411 docs(perf): publish comprehensive performance benchmark results before and after optimizations 2026-10-20 16:23:10 -03:00
Claudecio Martins 49021d9942 fix(ui): eliminate SpotBugs warnings for volatile developmentMode and prompt listener 2026-10-20 16:20:22 -03:00
Claudecio Martins 67e47cbed5 perf(crypto): use forced certificate rediscovery when hint is not present in cache 2026-10-20 16:15:30 -03:00
Claudecio Martins b00f78505d perf(protocol): enable virtual threads for ccsp server and network io 2026-10-20 16:09:27 -03:00
Claudecio Martins a32ab3b7ce perf(security): implement adaptive smartcard polling and on-demand certificate caching 2026-10-20 16:06:29 -03:00
Claudecio Martins e3d56d90af perf(pdf): use random access read and streaming for large pdf signing 2026-10-20 16:01:28 -03:00
Claudecio Martins 051614a919 perf(app): decouple javafx toolkit for fast ccsp startup on demand 2026-10-20 15:52:50 -03:00
Claudecio Martins bac691aae1 test(bench): add automated benchmark scripts and document baseline metrics 2026-10-20 15:47:38 -03:00
Claudecio Martins 71428cdaad fix(spotbugs): update filter justifications and seal finalizer mitigation on TsaClient 2026-10-20 15:40:41 -03:00
Claudecio Martins cf7ac8c51c docs: publish hardware compatibility matrix and align pades icp-brasil policy documentation 2026-10-20 15:35:21 -03:00
Claudecio Martins 002823ede3 feat(crypto): add certificate chain validation and structured ccsp error mapping 2026-10-20 15:33:08 -03:00
Claudecio Martins 90e2c8ef3d feat(crypto): implement resilient tsa client with fallback and ocsp/crl cache with structured errors 2026-10-20 15:31:47 -03:00
Claudecio Martins 4b6ae923c6 test(pkcs11): add softhsm2 integration test with assumptions and configure ci pipeline 2026-10-20 15:26:39 -03:00
Claudecio Martins 4b64a113b5 test(compliance): add independent conformance test suite for pades and xades and iti guide 2026-10-20 15:24:00 -03:00
Claudecio Martins 06c90b4e86 ci(security): generate cyclonedx sbom, add sbom upload to ci and update stride security model 2026-10-20 15:13:52 -03:00
Claudecio Martins 300944697f feat(security): prevent dev mode activation on production flavor builds 2026-10-20 15:03:30 -03:00
Claudecio Martins 7aa33a4789 test(security): add comprehensive tests for noncemanager ttl, concurrency and clock skew 2026-10-20 15:00:50 -03:00
Claudecio Martins a562909bc5 feat(core): support configurable port, clear collision error handling and discovery proposal 2026-10-20 14:59:25 -03:00
Claudecio Martins 66459bfae4 feat(ui): enhance signature confirmation dialog with wysiwys details and update ccsp spec 2026-10-20 14:56:08 -03:00
Claudecio Martins 1a727e6ff5 feat(security): enforce strict host validation and authorized origin checks on ccsp routes 2026-10-20 14:53:10 -03:00
Claudecio Martins eeeecb0604 feat(security): protect authorized-apps.json integrity with hmac and restricted permissions 2026-10-20 14:50:34 -03:00
Claudecio Martins 4e6dd2a029 docs: document semantic versioning strategy and v0.1.0 release guide 2026-10-20 14:36:41 -03:00
Claudecio Martins cd3ef646d2 ci(renovate): configure automated dependency updates with grouping 2026-10-20 14:35:27 -03:00
Claudecio Martins fa93e31ca1 docs: add changelog, contributing guide and gitea issue/pr templates 2026-10-20 14:35:04 -03:00
Claudecio Martins 6093879aa9 ci: add gitea actions ci workflow with maven cache and artifacts 2026-10-20 14:33:59 -03:00
Claudecio Martins cebeca467f test(coverage): add jacoco check thresholds and update spotbugs filter 2026-10-20 14:33:29 -03:00
Claudecio Martins e50257fb34 chore(repo): move asset generation script and ignore scratch folder 2026-10-20 14:25:56 -03:00
Claudecio Martins 6dce9f9317 chore(build): align JDK requirement to Java 25 LTS across docs and scripts 2026-10-20 14:25:21 -03:00
82 changed files with 6837 additions and 224 deletions
+31
View File
@@ -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
+26
View File
@@ -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.
+21
View File
@@ -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 #
+112
View File
@@ -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
+5 -1
View File
@@ -77,4 +77,8 @@ $RECYCLE.BIN/
*~
# Arquivos temporários de Scratch locais
scratch/temp/
scratch/
# Agentes e caches locais
.agents/**/cache/
.agents/**/temp/
+56
View File
@@ -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')`.
+62
View File
@@ -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.
+3 -3
View File
@@ -2,13 +2,13 @@
> **Agente Desktop Nativo de Assinatura Digital e Autenticação Criptográfica (A1 / A3 / PKCS#11) para Windows e Linux.**
[![Java 21+](https://img.shields.io/badge/Java-21%2B%20LTS-orange.svg)](https://openjdk.org/)
[![Java 25](https://img.shields.io/badge/Java-25%20LTS-orange.svg)](https://openjdk.org/)
[![JavaFX 25](https://img.shields.io/badge/JavaFX-25-blue.svg)](https://openjfx.io/)
[![Protocol CCSP/1](https://img.shields.io/badge/Protocol-CCSP%2F1-green.svg)](docs/CCSP.md)
[![Standard ICP-Brasil](https://img.shields.io/badge/Standard-ICP--Brasil-emerald.svg)](https://www.gov.br/iti/pt-br)
[![License](https://img.shields.io/badge/License-Proprietary-darkred.svg)](#licença)
O **CyberCore Signer** é uma solução desktop desenvolvida em Java 21+ LTS (homologada 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.
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.
@@ -50,7 +50,7 @@ console.log('Certificado titular:', result.subject_name);
### ☕ Para Desenvolvedores Desktop
#### Requisitos
* **Java Development Kit (JDK):** Versão 21+ LTS (OpenJDK, Temurin ou Corretto).
* **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.
+1 -2
View File
@@ -52,13 +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
+162 -3
View File
@@ -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>
+50 -11
View File
@@ -152,11 +152,20 @@ Solicita a assinatura digital local do digest informado com o certificado escolh
"certificate_hint": "win-4340656a",
"document_name": "contrato_prestacao_servicos.pdf",
"document_type": "PDF",
"document_size": 1474560,
"origin": "https://meusistema.empresa.com.br",
"nonce": "c4ca4238a0b923820dcc509a6f75849b",
"expires_at": "2026-10-11T20:50:00Z"
}
```
> **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
{
@@ -348,22 +357,52 @@ Quando qualquer operação falha, o servidor local CCSP/1 responde com um objeto
}
```
### Mapeamento de Status HTTP
### Lista Completa de Códigos de Erro Estruturados
| Código HTTP | Erro Típico | Descrição |
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 |
|---|---|---|
| `400 Bad Request` | `USER_CANCELLED`, `PIN_INCORRECT`, `SIGNATURE_FAILED` | Requisição malformada, cancelamento pelo usuário ou falha de PIN |
| `401 Unauthorized` | `APPLICATION_NOT_AUTHORIZED` | Aplicação não autorizada ou sem cabeçalho `X-CyberCore-AppId` |
| `403 Forbidden` | `INVALID_ORIGIN` | Cabeçalho `Origin` não confere com o domínio cadastrado |
| `404 Not Found` | `CERTIFICATE_NOT_FOUND`, `TOKEN_NOT_FOUND` | Certificado ou dispositivo físico ausente |
| `409 Conflict` | `REPLAY_DETECTED` | Nonce já utilizado anteriormente na janela de validação |
| `422 Unprocessable`| `REQUEST_EXPIRED`, `CERTIFICATE_EXPIRED` | Parâmetros sintaticamente válidos mas expirados temporalmente |
| `500 Server Error` | `PKCS11_ERROR`, `PROVIDER_NOT_AVAILABLE` | Falha interna no middleware do fabricante ou driver criptográfico |
| `503 Service Unavail`| `TOKEN_REMOVED`, `COMMUNICATION_ERROR` | Hardware desconectado ou serviço em recuperação |
| `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. Diagrama de Sequência Completo
## 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
+8 -1
View File
@@ -2,7 +2,7 @@
> **Í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 21+ LTS (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.
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.
---
@@ -37,6 +37,7 @@ Se você precisa implantar, homologar ou validar os aspectos de conformidade e s
* **[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).
@@ -56,9 +57,15 @@ Se você precisa implantar, homologar ou validar os aspectos de conformidade e s
| [`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 |
---
+1 -1
View File
@@ -2,7 +2,7 @@
> **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 21+ LTS (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.
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.
---
+129
View File
@@ -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);
}
}
```
+417
View File
@@ -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
}
}
}
+53
View File
@@ -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/`.
+65
View File
@@ -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 -1
View File
@@ -11,7 +11,7 @@ O **CyberCore Signer** conta com um subsistema nativo de diagnóstico e telemetr
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 Nativo:**
- Versão do Java (requer Java 21+ LTS, homologado em Java 25 LTS);
- 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:**
+1 -1
View File
@@ -20,7 +20,7 @@ O **CyberCore Signer** foi projetado para distribuição nativa através da ferr
### 2.1 Pré-requisitos para a Máquina de Build
Para compilar e gerar o pacote de instalação no Windows:
1. **JDK 21+ LTS:** Com a variável `JAVA_HOME` devidamente configurada.
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
+14
View File
@@ -142,3 +142,17 @@ const result = await signer.signPdfDocument({
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 |
+82
View File
@@ -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.
+88
View File
@@ -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.
+110
View File
@@ -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
```
+99
View File
@@ -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();
```
+11
View File
@@ -202,3 +202,14 @@ try {
}
}
```
---
## 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)
+46 -44
View File
@@ -1,4 +1,4 @@
# Modelo de Segurança e Privacidade
# Modelo de Segurança, Privacidade e Análise de Ameaças (STRIDE)
> **Diretrizes de Segurança em Profundidade, Proteção Criptográfica e Conformidade LGPD**
@@ -6,66 +6,68 @@ O **CyberCore Signer** foi concebido sob os princípios de *Security by Design*
---
## 1. Isolamento de Rede e Proteção Local
## 1. Modelo de Ameaças STRIDE e Matriz de Mitigações
### 1.1 Ligação Estrita ao Loopback (`127.0.0.1`)
| 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. Comunicação Local e Arquitetura de Transporte (HTTP vs WebSocket)
### 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. Isolamento de Rede e Proteção Local
### 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.
### 1.2 Proteção Contra Ataques de DNS Rebinding
### 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`.
* Somente requisições com `Host: 127.0.0.1:21325` ou `Host: localhost:21325` são aceitas; qualquer outro valor é rejeitado com status `403 Forbidden`.
### 1.3 Controle de Origem (CORS) e Preflight
* Requisições prévias HTTP `OPTIONS` são validadas contra as origens web homologadas.
* O cabeçalho `Access-Control-Allow-Origin` é devolvido estritamente com a origem solicitante autorizada (nunca com wildcard `*`).
* 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`).
---
## 2. Autenticação e Autorização de Aplicações
## 4. Integridade Criptográfica do Registro de Aplicações (`ApplicationRegistry`)
Nenhuma aplicação web externa tem acesso a certificados digitais ou serviços de assinatura sem autorização prévia e explícita do usuário:
1. **Handshake de Aprovação (`POST /ccsp/v1/authorize`):**
- Ao tentar comunicar-se pela primeira vez, a aplicação web deve fornecer seu `application_id` (UUID estável) e seu nome amigável.
- O CyberCore Signer suspende a requisição e exibe um modal nativo no desktop informando o nome da aplicação e solicitando autorização do usuário.
2. **Armazenamento Seguro de Permissões:**
- As aplicações autorizadas são gravadas no arquivo local `~/.cybercore/authorized-apps.json`.
- O usuário pode visualizar e **revogar a qualquer momento** a autorização de qualquer aplicação através da aba **Configurações** na interface gráfica.
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.
---
## 3. Proteção Contra Ataques de Replay
## 5. Proteção de Memória e Ciclo de Vida do PIN/Chaves
Para impedir que requisições de assinatura capturadas por softwares maliciosos na máquina sejam reenviadas:
1. **Identificador Único (`nonce`):** Toda requisição ao `/ccsp/v1/sign` deve conter um UUID aleatório criptograficamente forte gerado pelo chamador.
2. **Janela de Validade Temporal (`expires_at`):** A requisição inclui uma estampa de tempo limite (TTL).
3. **Gerenciador de Nonces (`NonceManager`):**
- Mantém uma estrutura em memória com os identificadores recentes;
- Se o mesmo `nonce` for submetido mais de uma vez dentro da janela de validade, a operação é rejeitada com erro estruturado `REPLAY_DETECTED` (HTTP `409 Conflict`);
- Se a data/hora atual ultrapassar `expires_at`, a operação é rejeitada com erro `REQUEST_EXPIRED` (HTTP `422 Unprocessable Entity`).
---
## 4. Confirmação Visual Obrigatória e Proteção de 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 silenciosa ou 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 criptográficos do hardware, garantindo que a chave nunca deixe o chip criptográfico.
3. **Higienização Segura de Memória Volátil:**
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 o carregamento da chave, o array é sobrescrito com zeros através de `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 durante ou após a assinatura, o `SmartCardWatcher` força a limpeza imediata do cache de sessão e dos buffers do sistema operacional.
- 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.
---
## 5. Privacidade de Dados e Conformidade com a LGPD
## 6. Software Bill of Materials (SBOM) e Cadeia de Suprimentos
1. **Assinatura Descentralizada por Digest:** O CyberCore Signer opera prioritariamente recebendo apenas o hash SHA-256 do arquivo. O conteúdo completo do documento (contrato, laudo, prontuário) não transita pela API local nem é gravado em logs.
2. **Mascaramento e Sigilo de CPF:** Nos selos visuais em PDF, o usuário pode configurar o modo de exibição de seu CPF (`CpfDisplayMode`):
- `FULL`: Exibição completa (`123.456.789-00`);
- `MASKED`: Dígitos sensíveis ocultados (`***.456.789-**`);
- `NONE`: Remoção completa do CPF do gráfico visual do selo.
3. **Auditoria e Logs Transparentes:** Os logs do agente registram apenas identificadores de correlação (`request_id`), algoritmo utilizado e status da operação, sem armazenar hashes de documentos sigilosos ou dados pessoais desnecessários.
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.
+51
View File
@@ -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();
@@ -68,37 +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);
Runtime.getRuntime().addShutdownHook(new Thread(() ->
logger.warn("JVM em encerramento (sinal externo, System.exit ou fim do aplicativo)."),
"cybercore-shutdown-diagnostic"));
Application.launch(CyberCoreDesktopApplication.class, args);
} catch (Throwable t) {
logger.warn("Não foi possível inicializar ambiente gráfico ({}). Continuando em modo console.", t.getMessage());
startHeadlessServer();
}
}
logger.info("CyberCore Signer finalizado.");
}
private static void startHeadlessServer() {
try {
// 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);
runningServer = new CcspServer(certManager, signingService, null);
br.com.cybercore.signer.core.protocol.CcspUserPromptListener promptListener =
new br.com.cybercore.signer.ui.prompt.JavaFxPromptListener();
try {
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());
@@ -106,22 +119,74 @@ public class CyberCoreSignerApplication {
logger.info("Encerrando servidor local CCSP/1...");
stopServer();
}));
} 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) {
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();
} catch (Exception e) {
logger.error("Falha ao inicializar servidor local CCSP/1 no modo console: {}", e.getMessage(), e);
}
}
private static void parseArguments(String[] args) {
logger.info("CyberCore Signer finalizado.");
}
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)) {
@@ -129,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);
}
}
}
}
@@ -0,0 +1,2 @@
build.version=${project.version}
build.flavor=${cybercore.build.flavor}
@@ -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);
}
}
+37
View File
@@ -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;
}
}
@@ -10,6 +10,9 @@ 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;
@@ -83,7 +86,19 @@ public class PdfSigningService {
*/
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();
}
}
@@ -93,7 +108,24 @@ public class PdfSigningService {
*/
public static PageDimensions getPageDimensions(byte[] pdfBytes, int pageIndex0Based) 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());
}
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());
}
@@ -108,7 +140,8 @@ public class PdfSigningService {
*/
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());
}
@@ -128,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);
}
logger.info("PDF assinado com sucesso e salvo em: {}", targetPdf.getAbsolutePath());
}
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().replaceAll("[\r\n]", ""));
}
/**
@@ -155,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()) {
@@ -186,8 +284,6 @@ public class PdfSigningService {
String rawName = cert.commonName() != null ? cert.commonName() : cert.alias();
String signerName = X509CertificateUtils.extractHolderName(rawName);
MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
byte[] pdfDigest = sha256.digest(pdfBytes);
String hashShort = bytesToHex(pdfDigest).substring(0, 16).toUpperCase();
PdfSealTheme theme = options.sealTheme();
@@ -213,8 +309,7 @@ public class PdfSigningService {
// 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.
ByteArrayOutputStream finalOut = new ByteArrayOutputStream();
try (PDDocument doc = Loader.loadPDF(pdfBytes)) {
try (PDDocument doc = Loader.loadPDF(rar)) {
PDSignature signature = new PDSignature();
signature.setFilter(PDSignature.FILTER_ADOBE_PPKLITE);
signature.setSubFilter(PDSignature.SUBFILTER_ADBE_PKCS7_DETACHED);
@@ -235,16 +330,13 @@ public class PdfSigningService {
signatureOptions.setVisualSignature(visualTemplate);
signatureOptions.setPage(targetPage);
doc.addSignature(signature, signer, signatureOptions);
doc.saveIncremental(finalOut);
doc.saveIncremental(targetOut);
}
} else {
doc.addSignature(signature, signer);
doc.saveIncremental(finalOut);
doc.saveIncremental(targetOut);
}
}
logger.info("Assinatura PAdES concluída com sucesso. Tamanho final: {} bytes", finalOut.size());
return finalOut.toByteArray();
}
private int resolveTargetPage(PDDocument doc, PdfSignatureOptions options) {
@@ -116,7 +116,13 @@ public record CcspRequest(
String targetElementTag,
@JsonProperty("target_element_id")
String targetElementId
String targetElementId,
@JsonProperty("document_size")
Long documentSize,
@JsonProperty("origin")
String origin
) implements Serializable {
public static final String PROTOCOL_VERSION = "1";
@@ -130,7 +136,8 @@ public record CcspRequest(
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, null, null, null, null,
null, null);
}
public boolean isVersionSupported() {
@@ -55,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;
@@ -120,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
@@ -929,6 +927,7 @@ public class CcspServer {
resp.put("status", "success");
resp.put("default_seal_id", PdfSealThemeRegistry.getDefaultTheme().id());
resp.put("seals", sealList);
resp.put("themes", sealList);
sendJsonResponse(exchange, 200, resp);
}
@@ -1046,7 +1045,45 @@ public class CcspServer {
return false;
}
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;
}
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);
@@ -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,16 +20,57 @@ 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();
}
/**
@@ -65,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());
}
@@ -76,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();
}
}
@@ -85,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();
@@ -119,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;
}
/**
@@ -197,6 +258,7 @@ public class CertificateManager {
* Solicita recarregamento de todos os provedores cadastrados.
*/
public void refreshAll() {
invalidateCache();
for (CertificateProvider provider : providers) {
try {
provider.refresh();
@@ -204,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.
*
@@ -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."
);
}
}
}
}
@@ -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);
});
}
}
@@ -289,4 +289,118 @@ class PdfSigningServiceTest {
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 -> {}
}
}
}
@@ -615,4 +615,42 @@ class CcspServerTest {
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");
}
}
}
@@ -93,4 +93,51 @@ class CertificateManagerTest {
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();
}
}
@@ -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);
});
}
}
+37
View File
@@ -53,4 +53,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.05</minimum>
</limit>
<limit>
<counter>INSTRUCTION</counter>
<value>COVEREDRATIO</value>
<minimum>0.05</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -72,15 +72,25 @@ public class CyberCoreDesktopApplication extends Application {
private static final Logger logger = LoggerFactory.getLogger(CyberCoreDesktopApplication.class);
private static boolean developmentMode = false;
private static volatile boolean developmentMode = false;
private static MainAppView mainAppView;
private static volatile CcspServer sharedCcspServer = null;
private static volatile CertificateManager sharedCertManager = null;
private static volatile SigningService sharedSigningService = null;
private CertificateManager certificateManager;
private SigningService signingService;
private CcspServer ccspServer;
private SystemTrayManager systemTrayManager;
private SmartCardWatcher smartCardWatcher;
public static void setSharedServices(CcspServer server, CertificateManager certManager, SigningService signingService) {
sharedCcspServer = server;
sharedCertManager = certManager;
sharedSigningService = signingService;
}
public static void setDevelopmentMode(boolean devMode) {
developmentMode = devMode;
}
@@ -93,10 +103,12 @@ public class CyberCoreDesktopApplication extends Application {
public void start(Stage primaryStage) {
logger.info("Iniciando interface gráfica JavaFX...");
Platform.setImplicitExit(false);
br.com.cybercore.signer.ui.prompt.JavaFxPromptListener.markToolkitInitialized();
br.com.cybercore.signer.ui.prompt.JavaFxPromptListener.setActiveStage(primaryStage);
// Inicializa serviços centrais reais da plataforma
this.certificateManager = CertificateManager.createDefault();
this.signingService = new SigningService(this.certificateManager);
// Inicializa ou reutiliza serviços centrais reais da plataforma
this.certificateManager = (sharedCertManager != null) ? sharedCertManager : CertificateManager.createDefault();
this.signingService = (sharedSigningService != null) ? sharedSigningService : new SigningService(this.certificateManager);
if (developmentMode) {
logger.info("Modo de desenvolvimento ativo (utilizando certificados reais da máquina).");
@@ -419,6 +431,12 @@ public class CyberCoreDesktopApplication extends Application {
}
private void initCcspServer(Stage stage) {
if (sharedCcspServer != null) {
this.ccspServer = sharedCcspServer;
logger.info("Vinculada interface desktop ao servidor CCSP/1 já ativo na porta {}", this.ccspServer.getPort());
return;
}
CcspUserPromptListener promptListener = new CcspUserPromptListener() {
@Override
public CompletableFuture<Boolean> requestApplicationAuthorization(String origin, String appName, String applicationId) {
@@ -472,9 +490,31 @@ public class CyberCoreDesktopApplication extends Application {
.map(AuthorizedApplication::name)
.orElse(request.applicationId());
Long docSize = request.documentSize();
if (docSize == null && request.pdf() != null) {
try {
docSize = (long) Base64.getDecoder().decode(request.pdf()).length;
} catch (IllegalArgumentException e) {
logger.debug("Não foi possível decodificar base64 do PDF: {}", e.getMessage());
}
} else if (docSize == null && request.xmlBase64() != null) {
try {
docSize = (long) Base64.getDecoder().decode(request.xmlBase64()).length;
} catch (IllegalArgumentException e) {
logger.debug("Não foi possível decodificar base64 do XML: {}", e.getMessage());
}
}
String requestOrigin = request.origin();
if (requestOrigin == null || requestOrigin.isBlank()) {
requestOrigin = ApplicationRegistry.getInstance().findById(request.applicationId())
.map(a -> String.join(", ", a.allowedOrigins()))
.orElse("Requisição Web Local");
}
SignaturePromptDialog dialog = new SignaturePromptDialog(
stage, appName, "Requisição Web CCSP", certificate, context,
request.documentName(), request.documentType(), request.isAuthentication()
stage, appName, requestOrigin, certificate, context,
request.documentName(), request.documentType(), docSize, request.isAuthentication()
);
dialog.showAndAwait().whenComplete((approved, err) -> {
if (err != null) {
@@ -492,21 +532,34 @@ public class CyberCoreDesktopApplication extends Application {
}
};
int targetPort = resolveConfiguredPort();
try {
this.ccspServer = new CcspServer(this.certificateManager, this.signingService, promptListener);
this.ccspServer = new CcspServer(targetPort, this.certificateManager, this.signingService, ApplicationRegistry.getInstance(), promptListener);
this.ccspServer.start();
logger.info("Servidor local CCSP/1 inicializado na porta {}", this.ccspServer.getPort());
} catch (java.net.BindException be) {
logger.warn("Porta 21325 já está ocupada por outra instância do CyberCore Signer em execução. O agente continuará ativo na interface gráfica.");
logger.warn("ALERTA DE COLISÃO DE PORTA: A porta {} já está ocupada por outra instância do CyberCore Signer ou outro serviço. Para usar outra porta, configure CYBERCORE_PORT ou use --port <porta>. O agente continuará ativo na interface gráfica.", targetPort);
} catch (Exception e) {
if (e.getCause() instanceof java.net.BindException || (e.getMessage() != null && e.getMessage().contains("Address already in use"))) {
logger.warn("Porta 21325 já está ocupada por outra instância do CyberCore Signer em execução. O agente continuará ativo na interface gráfica.");
logger.warn("ALERTA DE COLISÃO DE PORTA: A porta {} já está ocupada por outra instância do CyberCore Signer ou outro serviço. Para usar outra porta, configure CYBERCORE_PORT ou use --port <porta>. O agente continuará ativo na interface gráfica.", targetPort);
} else {
logger.error("Falha ao inicializar servidor local CCSP/1: {}", e.getMessage(), e);
}
}
}
private int resolveConfiguredPort() {
String env = System.getenv("CYBERCORE_PORT");
if (env != null && !env.isBlank()) {
try { return Integer.parseInt(env.trim()); } catch (Exception ignored) {}
}
String prop = System.getProperty("cybercore.port");
if (prop != null && !prop.isBlank()) {
try { return Integer.parseInt(prop.trim()); } catch (Exception ignored) {}
}
return CcspServer.DEFAULT_PORT;
}
private void initSmartCardWatcher() {
if (Boolean.getBoolean("cybercore.diag.no-smartcard-watcher")) {
logger.warn("Diagnóstico: Monitor dinâmico de smart cards DESATIVADO via -Dcybercore.diag.no-smartcard-watcher=true");
@@ -0,0 +1,150 @@
package br.com.cybercore.signer.ui.prompt;
import br.com.cybercore.signer.core.domain.ManagedCertificate;
import br.com.cybercore.signer.core.domain.SigningContext;
import br.com.cybercore.signer.core.protocol.CcspRequest;
import br.com.cybercore.signer.core.protocol.CcspUserPromptListener;
import br.com.cybercore.signer.core.security.ApplicationRegistry;
import br.com.cybercore.signer.core.security.AuthorizedApplication;
import br.com.cybercore.signer.ui.view.ApplicationApprovalDialog;
import br.com.cybercore.signer.ui.view.SignaturePromptDialog;
import javafx.application.Platform;
import javafx.stage.Stage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.Base64;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* Listener desacoplado de prompts do CCSP para a interface JavaFX.
* Garante que o toolkit JavaFX só seja inicializado sob demanda (on-demand)
* quando houver um diálogo visual de confirmação a ser exibido ao usuário.
*/
public class JavaFxPromptListener implements CcspUserPromptListener {
private static final Logger logger = LoggerFactory.getLogger(JavaFxPromptListener.class);
private static final AtomicBoolean FX_TOOLKIT_INITIALIZED = new AtomicBoolean(false);
private static volatile Stage activeStage = null;
public static void setActiveStage(Stage stage) {
activeStage = stage;
}
public static void markToolkitInitialized() {
FX_TOOLKIT_INITIALIZED.set(true);
}
public static void runOnFxThread(Runnable action) {
if (FX_TOOLKIT_INITIALIZED.get()) {
Platform.runLater(action);
} else {
try {
Platform.startup(() -> {
FX_TOOLKIT_INITIALIZED.set(true);
Platform.setImplicitExit(false);
action.run();
});
} catch (IllegalStateException e) {
FX_TOOLKIT_INITIALIZED.set(true);
Platform.runLater(action);
}
}
}
@Override
public CompletableFuture<Boolean> requestApplicationAuthorization(String origin, String appName, String applicationId) {
CompletableFuture<Boolean> future = new CompletableFuture<>();
runOnFxThread(() -> {
try {
ApplicationApprovalDialog dialog = new ApplicationApprovalDialog(activeStage, origin, appName, applicationId);
dialog.showAndAwait().whenComplete((approved, err) -> {
if (err != null) {
future.complete(false);
} else {
future.complete(Boolean.TRUE.equals(approved));
}
});
} catch (Exception ex) {
logger.error("Erro ao exibir diálogo de autorização de aplicação: {}", ex.getMessage());
future.complete(false);
}
});
return future;
}
@Override
public CompletableFuture<Boolean> requestSignatureConfirmation(CcspRequest request, ManagedCertificate certificate) {
CompletableFuture<Boolean> future = new CompletableFuture<>();
runOnFxThread(() -> {
try {
byte[] digestBytes;
if (request.digest() != null && !request.digest().isBlank()) {
digestBytes = Base64.getDecoder().decode(request.digest());
} else if (request.pdf() != null && !request.pdf().isBlank()) {
byte[] rawPdf = Base64.getDecoder().decode(request.pdf());
digestBytes = MessageDigest.getInstance("SHA-256").digest(rawPdf);
} else if (request.challenge() != null && !request.challenge().isBlank()) {
digestBytes = MessageDigest.getInstance("SHA-256")
.digest(request.challenge().getBytes(StandardCharsets.UTF_8));
} else {
digestBytes = new byte[32];
}
SigningContext context = new SigningContext(
request.requestId(),
request.applicationId(),
digestBytes,
request.algorithm() != null ? request.algorithm() : "SHA256withRSA",
certificate.id(),
Instant.now()
);
String appName = ApplicationRegistry.getInstance().findById(request.applicationId())
.map(AuthorizedApplication::name)
.orElse(request.applicationId());
Long docSize = request.documentSize();
if (docSize == null && request.pdf() != null) {
try {
docSize = (long) Base64.getDecoder().decode(request.pdf()).length;
} catch (IllegalArgumentException ignored) {
}
} else if (docSize == null && request.xmlBase64() != null) {
try {
docSize = (long) Base64.getDecoder().decode(request.xmlBase64()).length;
} catch (IllegalArgumentException ignored) {
}
}
String requestOrigin = request.origin();
if (requestOrigin == null || requestOrigin.isBlank()) {
requestOrigin = ApplicationRegistry.getInstance().findById(request.applicationId())
.map(a -> String.join(", ", a.allowedOrigins()))
.orElse("Requisição Web Local");
}
SignaturePromptDialog dialog = new SignaturePromptDialog(
activeStage, appName, requestOrigin, certificate, context,
request.documentName(), request.documentType(), docSize, request.isAuthentication()
);
dialog.showAndAwait().whenComplete((approved, err) -> {
if (err != null) {
future.complete(false);
} else {
future.complete(Boolean.TRUE.equals(approved));
}
});
} catch (Exception ex) {
logger.error("Erro ao exibir diálogo de confirmação de assinatura: {}", ex.getMessage());
future.complete(false);
}
});
return future;
}
}
@@ -36,12 +36,18 @@ public class SignaturePromptDialog {
public SignaturePromptDialog(Stage owner, String applicationName, String origin,
ManagedCertificate certificate, SigningContext context) {
this(owner, applicationName, origin, certificate, context, null, null, false);
this(owner, applicationName, origin, certificate, context, null, null, null, false);
}
public SignaturePromptDialog(Stage owner, String applicationName, String origin,
ManagedCertificate certificate, SigningContext context,
String documentName, String documentType, boolean isAuthentication) {
this(owner, applicationName, origin, certificate, context, documentName, documentType, null, isAuthentication);
}
public SignaturePromptDialog(Stage owner, String applicationName, String origin,
ManagedCertificate certificate, SigningContext context,
String documentName, String documentType, Long documentSize, boolean isAuthentication) {
stage = new Stage();
stage.initModality(Modality.APPLICATION_MODAL);
if (owner != null) {
@@ -52,11 +58,11 @@ public class SignaturePromptDialog {
: "CyberCore Signer — Solicitação de Assinatura");
stage.setResizable(false);
initLayout(applicationName, origin, certificate, context, documentName, documentType, isAuthentication);
initLayout(applicationName, origin, certificate, context, documentName, documentType, documentSize, isAuthentication);
}
private void initLayout(String appName, String origin, ManagedCertificate cert, SigningContext context,
String documentName, String documentType, boolean isAuthentication) {
String documentName, String documentType, Long documentSize, boolean isAuthentication) {
VBox root = new VBox(16);
root.getStyleClass().add("dialog-container");
root.setPadding(new Insets(20, 24, 20, 24));
@@ -94,7 +100,8 @@ public class SignaturePromptDialog {
grid.setVgap(10);
int row = 0;
addField(grid, row++, "Sistema:", appName + " (" + (origin != null ? origin : "Local") + ")");
addField(grid, row++, "Aplicação Solicitante:", appName);
addField(grid, row++, "Origem Web:", (origin != null && !origin.isBlank() ? origin : "Local"));
if (isAuthentication) {
addField(grid, row++, "Operação:", "Autenticação de Usuário (Challenge-Response)");
} else {
@@ -102,7 +109,12 @@ public class SignaturePromptDialog {
String docLabel = (documentType != null && !documentType.isBlank())
? "[" + documentType.toUpperCase() + "] " + documentName
: documentName;
if (documentSize != null && documentSize > 0) {
docLabel += " (" + formatFileSize(documentSize) + ")";
}
addField(grid, row++, "Documento:", docLabel);
} else if (documentSize != null && documentSize > 0) {
addField(grid, row++, "Tamanho do Documento:", formatFileSize(documentSize));
}
addField(grid, row++, "Operação:", "Assinatura Digital (Digest)");
}
@@ -128,14 +140,16 @@ public class SignaturePromptDialog {
detailsBox.getChildren().add(grid);
// Caixa do Hash / Digest
// Caixa do Hash / Digest formatado em blocos legíveis (WYSIWYS)
VBox hashBox = new VBox(6);
Label hashLabel = new Label("Hash do Documento (SHA-256 Digest):");
Label hashLabel = new Label("Hash do Documento (SHA-256 Digest formatado):");
hashLabel.getStyleClass().add("dialog-field-title");
String hashHex = HexFormat.of().formatHex(context.digest());
Label hashValue = new Label(hashHex);
String formattedHash = formatHashBlocks(hashHex);
Label hashValue = new Label(formattedHash);
hashValue.getStyleClass().add("dialog-hash-value");
hashValue.setStyle("-fx-font-family: 'Liberation Mono', 'Courier New', monospace; -fx-font-size: 11px;");
hashValue.setWrapText(true);
hashBox.getChildren().addAll(hashLabel, hashValue);
@@ -301,4 +315,27 @@ public class SignaturePromptDialog {
stage.toFront();
return approvalFuture;
}
private static String formatFileSize(long bytes) {
if (bytes < 1024) {
return bytes + " B";
} else if (bytes < 1024 * 1024) {
return String.format(java.util.Locale.ROOT, "%.1f KB", bytes / 1024.0);
} else {
return String.format(java.util.Locale.ROOT, "%.1f MB", bytes / (1024.0 * 1024.0));
}
}
private static String formatHashBlocks(String hex) {
if (hex == null || hex.isBlank()) return "";
StringBuilder sb = new StringBuilder();
String upper = hex.toUpperCase(java.util.Locale.ROOT);
for (int i = 0; i < upper.length(); i++) {
if (i > 0 && i % 4 == 0) {
sb.append(' ');
}
sb.append(upper.charAt(i));
}
return sb.toString();
}
}
+29
View File
@@ -79,6 +79,7 @@
<spotbugs-maven-plugin.version>4.10.4.1</spotbugs-maven-plugin.version>
<findsecbugs-plugin.version>1.14.0</findsecbugs-plugin.version>
<javafx-maven-plugin.version>0.0.8</javafx-maven-plugin.version>
<cyclonedx-maven-plugin.version>2.9.1</cyclonedx-maven-plugin.version>
<!-- Qualidade -->
<spotbugs.skip>false</spotbugs.skip>
@@ -315,6 +316,21 @@
<artifactId>javafx-maven-plugin</artifactId>
<version>${javafx-maven-plugin.version}</version>
</plugin>
<plugin>
<groupId>org.cyclonedx</groupId>
<artifactId>cyclonedx-maven-plugin</artifactId>
<version>${cyclonedx-maven-plugin.version}</version>
<configuration>
<projectType>application</projectType>
<schemaVersion>1.6</schemaVersion>
<includeBomSerialNumber>true</includeBomSerialNumber>
<includeCompileScope>true</includeCompileScope>
<includeProvidedScope>true</includeProvidedScope>
<includeRuntimeScope>true</includeRuntimeScope>
<includeSystemScope>false</includeSystemScope>
<includeTestScope>false</includeTestScope>
</configuration>
</plugin>
</plugins>
</pluginManagement>
@@ -377,6 +393,19 @@
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.cyclonedx</groupId>
<artifactId>cyclonedx-maven-plugin</artifactId>
<executions>
<execution>
<id>cyclonedx-aggregate-bom</id>
<phase>package</phase>
<goals>
<goal>makeAggregateBom</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
+55
View File
@@ -0,0 +1,55 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"description": "Configuração do Renovate para o CyberCore Signer com suporte ao Gitea",
"extends": [
"config:recommended"
],
"dependencyDashboard": true,
"semanticCommits": "enabled",
"commitMessagePrefix": "chore(deps):",
"timezone": "America/Sao_Paulo",
"rebaseWhen": "auto",
"packageRules": [
{
"description": "Agrupamento de bibliotecas criptográficas Bouncy Castle",
"groupName": "Bouncy Castle",
"matchPackagePatterns": [
"^org\\.bouncycastle:"
]
},
{
"description": "Agrupamento do framework de renderização e manipulação de PDF Apache PDFBox",
"groupName": "Apache PDFBox",
"matchPackagePatterns": [
"^org\\.apache\\.pdfbox:"
]
},
{
"description": "Agrupamento de serialização e parsing JSON Jackson",
"groupName": "Jackson",
"matchPackagePatterns": [
"^com\\.fasterxml\\.jackson(\\..*)?:"
]
},
{
"description": "Agrupamento dos módulos da interface gráfica JavaFX (OpenJFX)",
"groupName": "JavaFX",
"matchPackagePatterns": [
"^org\\.openjfx:"
]
},
{
"description": "Agrupamento de frameworks de teste (JUnit, AssertJ, Mockito, ArchUnit)",
"groupName": "Test Dependencies",
"matchPackagePatterns": [
"^org\\.junit",
"^org\\.assertj:",
"^org\\.mockito:",
"^com\\.tngtech\\.archunit:"
]
}
],
"vulnerabilityAlerts": {
"enabled": true
}
}
+200
View File
@@ -0,0 +1,200 @@
package scripts.bench;
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.provider.CertificateManager;
import br.com.cybercore.signer.core.provider.Pkcs12CertificateProvider;
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.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 java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.math.BigInteger;
import java.nio.file.Files;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.KeyStore;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Date;
import java.util.List;
/**
* Utilitário de benchmark para medição de latência de certificados e assinatura PAdES (100KB, 5MB e 50MB).
*/
public class BenchmarkRunner {
private static final String TARGET_DIR = "target/bench-data";
public static void main(String[] args) throws Exception {
System.out.println("===============================================================");
System.out.println(" CyberCore Signer — Benchmark de Desempenho (Certificados & PAdES)");
System.out.println("===============================================================");
File dir = new File(TARGET_DIR);
if (!dir.exists()) {
dir.mkdirs();
}
// 1. Prepara Certificado A1 de Teste
System.out.print("[1/3] Inicializando provedor e certificado A1 de teste... ");
long tCertGenStart = System.currentTimeMillis();
CertificateManager certManager = new CertificateManager();
Pkcs12CertificateProvider a1Provider = new Pkcs12CertificateProvider();
KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
kpg.initialize(2048);
KeyPair keyPair = kpg.generateKeyPair();
X500Name name = new X500Name("CN=BENCHMARK TESTE:12345678900, O=ICP-Brasil Teste, C=BR");
Date notBefore = Date.from(Instant.now().minus(1, ChronoUnit.DAYS));
Date notAfter = Date.from(Instant.now().plus(30, ChronoUnit.DAYS));
X509v3CertificateBuilder builder = new X509v3CertificateBuilder(
name, BigInteger.ONE, notBefore, notAfter, name,
SubjectPublicKeyInfo.getInstance(keyPair.getPublic().getEncoded())
);
builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(false));
builder.addExtension(Extension.keyUsage, true, new KeyUsage(KeyUsage.digitalSignature | KeyUsage.nonRepudiation));
ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").build(keyPair.getPrivate());
X509CertificateHolder holder = builder.build(signer);
X509Certificate cert = new JcaX509CertificateConverter().getCertificate(holder);
KeyStore ks = KeyStore.getInstance("PKCS12");
ks.load(null, null);
ks.setKeyEntry("bench-alias", keyPair.getPrivate(), "1234".toCharArray(), new Certificate[]{cert});
ByteArrayOutputStream baosKs = new ByteArrayOutputStream();
ks.store(baosKs, "1234".toCharArray());
a1Provider.loadFromBytes(baosKs.toByteArray(), "1234".toCharArray());
certManager.registerProvider(a1Provider);
System.out.println("OK (" + (System.currentTimeMillis() - tCertGenStart) + " ms)");
// 2. Mede Tempo de Enumeração de Certificados
System.out.print("[2/3] Medindo tempo de enumeração de certificados (discoverCertificates)... ");
long tDiscStart = System.nanoTime();
List<ManagedCertificate> certs = certManager.discoverCertificates();
long tDiscEnd = System.nanoTime();
double discMs = (tDiscEnd - tDiscStart) / 1_000_000.0;
System.out.printf("OK (%.2f ms | %d certificado(s) listado(s))\n", discMs, certs.size());
String certId = certs.get(0).id();
// 3. Prepara arquivos PDF de 100 KB, 5 MB e 50 MB
System.out.println("[3/3] Preparando e assinando documentos PDF com PAdES e selo visual...");
File pdf100Kb = preparePdf(new File(dir, "doc_100kb.pdf"), 100 * 1024);
File pdf5Mb = preparePdf(new File(dir, "doc_5mb.pdf"), 5 * 1024 * 1024);
File pdf50Mb = preparePdf(new File(dir, "doc_50mb.pdf"), 50 * 1024 * 1024);
PdfSigningService pdfService = new PdfSigningService(certManager);
PdfSignatureOptions options = PdfSignatureOptions.builder(certId)
.position(PdfSealPosition.BOTTOM_RIGHT)
.visibleSeal(true)
.reason("Homologacao de Performance")
.location("Brasilia, DF")
.pinOrPassword("1234".toCharArray())
.build();
// Warmup JIT (PDF pequeno)
pdfService.signPdf(Files.readAllBytes(pdf100Kb.toPath()), options);
// Medição 100 KB
long t100Start = System.nanoTime();
byte[] signed100 = pdfService.signPdf(Files.readAllBytes(pdf100Kb.toPath()), options);
long t100End = System.nanoTime();
double ms100 = (t100End - t100Start) / 1_000_000.0;
// Medição 5 MB
long t5mStart = System.nanoTime();
byte[] signed5m = pdfService.signPdf(Files.readAllBytes(pdf5Mb.toPath()), options);
long t5mEnd = System.nanoTime();
double ms5m = (t5mEnd - t5mStart) / 1_000_000.0;
// Medição 50 MB
long t50mStart = System.nanoTime();
byte[] signed50m = pdfService.signPdf(Files.readAllBytes(pdf50Mb.toPath()), options);
long t50mEnd = System.nanoTime();
double ms50m = (t50mEnd - t50mStart) / 1_000_000.0;
System.out.println();
System.out.println("==================== RESULTADOS DE LATÊNCIA PAdES ====================");
System.out.printf(" - Enumeração de Certificados: %.2f ms\n", discMs);
System.out.printf(" - Assinatura PAdES 100 KB: %.2f ms (saída: %d KB)\n", ms100, signed100.length / 1024);
System.out.printf(" - Assinatura PAdES 5 MB: %.2f ms (saída: %.2f MB)\n", ms5m, signed5m.length / (1024.0 * 1024.0));
System.out.printf(" - Assinatura PAdES 50 MB: %.2f ms (saída: %.2f MB)\n", ms50m, signed50m.length / (1024.0 * 1024.0));
System.out.println("=======================================================================");
}
private static File preparePdf(File file, int targetSizeBytes) throws Exception {
if (file.exists() && file.length() >= targetSizeBytes * 0.95) {
return file;
}
System.out.printf(" -> Gerando %s (~%d KB)... ", file.getName(), targetSizeBytes / 1024);
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage();
doc.addPage(page);
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
cs.beginText();
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA_BOLD), 14);
cs.newLineAtOffset(50, 750);
cs.showText("CyberCore Signer — Documento de Benchmark de Performance");
cs.endText();
}
// Para arquivos de 5MB e 50MB, adiciona páginas e streams para atingir a meta de tamanho
int approxPageSize = 2500;
int pagesNeeded = Math.max(1, targetSizeBytes / approxPageSize);
for (int p = 1; p < pagesNeeded && p < 1200; p++) {
PDPage extraPage = new PDPage();
doc.addPage(extraPage);
try (PDPageContentStream cs = new PDPageContentStream(doc, extraPage)) {
cs.beginText();
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 10);
cs.newLineAtOffset(50, 700);
cs.showText("Pagina de teste de volume " + p + " com metadados para teste PAdES.");
cs.endText();
}
}
// Grava documento
try (FileOutputStream fos = new FileOutputStream(file)) {
doc.save(fos);
}
}
// Se ainda não atingiu o tamanho exato desejado (especialmente para 50MB), preenche comentários PDF (% padding)
long currentLen = file.length();
if (currentLen < targetSizeBytes) {
try (FileOutputStream fos = new FileOutputStream(file, true)) {
byte[] chunk = new byte[64 * 1024];
java.util.Arrays.fill(chunk, (byte) 'A');
chunk[0] = '%';
chunk[chunk.length - 1] = '\n';
while (file.length() < targetSizeBytes) {
long remaining = targetSizeBytes - file.length();
int toWrite = (int) Math.min(chunk.length, remaining);
fos.write(chunk, 0, toWrite);
}
}
}
System.out.printf("OK (%d KB)\n", file.length() / 1024);
return file;
}
}
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
set -euo pipefail
# Script de medição de tempo de inicialização e memória RSS em idle do CyberCore Signer
PORT=21325
JAR_FILE="modules/cybercore-app/target/cybercore-signer.jar"
JAVA_BIN="${JAVA_HOME:-/home/claudecio/.jdks/jdk-25.0.4.1+1}/bin/java"
if [ ! -f "$JAR_FILE" ]; then
echo "Erro: JAR $JAR_FILE não encontrado. Execute ./mvnw package primeiro."
exit 1
fi
# Garante que a porta esteja livre antes de iniciar
fuser -k "${PORT}/tcp" 2>/dev/null || true
sleep 1
# Timestamp inicial em milissegundos
START_TIME=$(date +%s%3N)
# Inicia o agente em modo headless
"$JAVA_BIN" -jar "$JAR_FILE" --headless --port "$PORT" > /dev/null 2>&1 &
APP_PID=$!
# Aguarda até que o endpoint CCSP /status responda HTTP 200
MAX_WAIT_SECONDS=15
READY=0
while true; do
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:${PORT}/ccsp/v1/status" 2>/dev/null || echo "000")
if [ "$HTTP_CODE" = "200" ]; then
READY=1
break
fi
CURRENT_TIME=$(date +%s%3N)
ELAPSED=$((CURRENT_TIME - START_TIME))
if [ "$ELAPSED" -gt $((MAX_WAIT_SECONDS * 1000)) ]; then
echo "Timeout aguardando inicialização do servidor CCSP."
kill -9 "$APP_PID" 2>/dev/null || true
exit 1
fi
sleep 0.05
done
END_TIME=$(date +%s%3N)
STARTUP_MS=$((END_TIME - START_TIME))
# Aguarda 2 segundos para estabilização de memória em repouso (idle)
sleep 2
# Mede RSS via ps (em KB)
RSS_KB=$(ps -o rss= -p "$APP_PID" | tr -d ' ' || echo "0")
RSS_MB=$(awk "BEGIN {printf \"%.2f\", $RSS_KB / 1024}")
# Encerra o processo limpo
kill "$APP_PID" 2>/dev/null || true
wait "$APP_PID" 2>/dev/null || true
echo "STARTUP_MS=${STARTUP_MS}"
echo "RSS_KB=${RSS_KB}"
echo "RSS_MB=${RSS_MB}"
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
set -euo pipefail
# Script mestre de execução e consolidação de benchmarks de performance do CyberCore Signer
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
JAVA_BIN="${JAVA_HOME:-/home/claudecio/.jdks/jdk-25.0.4.1+1}/bin/java"
JAVAC_BIN="${JAVA_HOME:-/home/claudecio/.jdks/jdk-25.0.4.1+1}/bin/javac"
JAR_FILE="modules/cybercore-app/target/cybercore-signer.jar"
echo "======================================================================="
echo " CYBERCORE SIGNER — SUÍTE DE MEDIÇÃO DE PERFORMANCE"
echo "======================================================================="
echo "Ambiente: $(uname -s) $(uname -m)"
echo "Java: $("$JAVA_BIN" -version 2>&1 | head -n 1)"
echo
# 1. Medições de Tempo de Inicialização e RSS (Média de 3 execuções)
echo "-----------------------------------------------------------------------"
echo "[1/2] Medindo Tempo de Inicialização do Servidor CCSP e Memória RSS..."
echo "-----------------------------------------------------------------------"
STARTUP_SUM=0
RSS_SUM=0
RUNS=3
for i in $(seq 1 $RUNS); do
echo -n " -> Execução $i de $RUNS: "
OUT=$(./scripts/bench/bench_startup_rss.sh)
S_MS=$(echo "$OUT" | grep "STARTUP_MS=" | cut -d'=' -f2)
R_MB=$(echo "$OUT" | grep "RSS_MB=" | cut -d'=' -f2)
echo "Startup = ${S_MS} ms | RSS = ${R_MB} MB"
STARTUP_SUM=$((STARTUP_SUM + S_MS))
RSS_SUM=$(awk "BEGIN {print $RSS_SUM + $R_MB}")
sleep 1
done
AVG_STARTUP=$((STARTUP_SUM / RUNS))
AVG_RSS=$(awk "BEGIN {printf \"%.2f\", $RSS_SUM / $RUNS}")
echo
echo "=> Média de Inicialização CCSP: ${AVG_STARTUP} ms"
echo "=> Média de Memória (RSS) em Idle: ${AVG_RSS} MB"
echo
# 2. Compilação e Execução do BenchmarkRunner (Certificados e PAdES)
echo "-----------------------------------------------------------------------"
echo "[2/2] Medindo Enumeração de Certificados e Latência PAdES..."
echo "-----------------------------------------------------------------------"
mkdir -p target/bench-classes
"$JAVAC_BIN" -cp "$JAR_FILE" -d target/bench-classes scripts/bench/BenchmarkRunner.java
"$JAVA_BIN" -cp "target/bench-classes:$JAR_FILE" scripts.bench.BenchmarkRunner
+86 -30
View File
@@ -1,42 +1,85 @@
# @cybercore/signer-sdk
SDK oficial em JavaScript para integração de aplicações web com o agente desktop **CyberCore Signer** via protocolo **CCSP/1**.
SDK oficial em JavaScript/TypeScript para integração de aplicações web com o agente desktop **CyberCore Signer** via protocolo **CCSP/1**.
Permite que sistemas web executem assinaturas digitais ICP-Brasil padrão **A1** (.pfx/.p12) e **A3** (Tokens USB e Smart Cards) instalados localmente na máquina do usuário, mantendo as chaves privadas e o PIN sob proteção estrita do hardware.
Permite que sistemas web solicitem assinaturas digitais ICP-Brasil padrão **A1** (.pfx/.p12) e **A3** (Tokens USB e Smart Cards via PKCS#11 / Windows Store) instalados localmente na máquina do usuário, mantendo as chaves privadas e o PIN sob proteção estrita e exclusiva do computador do cliente.
---
## 🚀 Instalação e Uso Rápido
## 📦 Instalação via Registry npm Privado
### 1. Inclusão Direta no HTML (ES Module)
```html
<script type="module">
import { CyberCoreSigner } from './src/signer-sdk.js';
Configure o escopo `@cybercore` no arquivo `.npmrc` da sua aplicação web:
```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
```
Em seguida, instale o pacote:
```bash
npm install @cybercore/signer-sdk
# ou
yarn add @cybercore/signer-sdk
# ou
pnpm add @cybercore/signer-sdk
```
---
## 🚀 Uso Rápido (TypeScript / JavaScript ES Modules)
```typescript
import {
CyberCoreSigner,
AgentNotRunningError,
UserCancelledError
} from '@cybercore/signer-sdk';
const signer = new CyberCoreSigner({
applicationName: 'Portal Minha Empresa'
applicationName: 'Portal Corporativo ERP',
timeoutMs: 30000 // 30 segundos
});
// 1. Verifica se o agente desktop está rodando
if (await signer.isInstalled()) {
console.log('Agente detectado!');
async function executarAssinatura() {
try {
// 1. Verifica se o agente desktop está ativo na bandeja do sistema
if (!(await signer.isInstalled())) {
alert('Por favor, abra o CyberCore Signer na bandeja do sistema.');
return;
}
// 2. Conecta e solicita autorização ao usuário
// 2. Conecta e obtém autorização visual do usuário
await signer.connect();
// 3. Obtém certificados disponíveis
const certs = await signer.getCertificates();
console.log('Certificados encontrados:', certs);
if (certs.length === 0) {
alert('Nenhum certificado A1 ou token A3 conectado.');
return;
}
// 4. Assina um documento (o SDK calcula o SHA-256 no próprio navegador)
const result = await signer.sign({
// 4. Assina o digest de um documento (o hash é gerado no próprio navegador)
const resultado = await signer.sign({
certificateId: certs[0].id,
data: 'Texto ou buffer do contrato para assinatura'
data: 'Conteúdo ou contrato em texto a ser assinado',
documentName: 'contrato.pdf'
});
console.log('Assinatura Base64:', result.signature);
console.log('Assinatura Base64:', resultado.signature);
console.log('Certificado X.509:', resultado.certificate);
} catch (err) {
if (err instanceof AgentNotRunningError) {
alert(err.actionableGuidance);
} else if (err instanceof UserCancelledError) {
console.info('Assinatura cancelada pelo usuário.');
} else {
console.error('Falha na operação:', err);
}
}
}
</script>
```
---
@@ -45,20 +88,33 @@ Permite que sistemas web executem assinaturas digitais ICP-Brasil padrão **A1**
| Método | Retorno | Descrição |
|---|---|---|
| `isInstalled(timeoutMs)` | `Promise<boolean>` | Verifica se o CyberCore Signer está ativo na porta local `21325`. |
| `getStatus()` | `Promise<Object>` | Retorna status de prontidão, versão e sistema operacional. |
| `getVersion()` | `Promise<Object>` | Retorna versões do agente e do protocolo CCSP. |
| `getCapabilities()` | `Promise<Object>` | Retorna algoritmos suportados (`SHA256withRSA`, etc.) e tipos de certificados. |
| `isInstalled(timeoutMs?)` | `Promise<boolean>` | Verifica se o CyberCore Signer está ativo na porta local (`21325`). |
| `getStatus(timeoutMs?)` | `Promise<StatusResponse>` | Retorna status de prontidão, versão e sistema operacional. |
| `getVersion()` | `Promise<VersionInfo>` | Retorna versões do agente e do protocolo CCSP. |
| `getCapabilities()` | `Promise<CapabilitiesResponse>` | Retorna algoritmos suportados (`SHA256withRSA`, etc.) e tipos de certificados. |
| `connect()` | `Promise<boolean>` | Realiza o handshake de autorização da aplicação web com o usuário. |
| `getCertificates()` | `Promise<Array>` | Retorna a lista de certificados A1 e A3 disponíveis na máquina. |
| `sign(options)` | `Promise<Object>` | Solicita a assinatura digital de um digest ou documento. |
| `verify(options)` | `Promise<boolean>` | Validação criptográfica da integridade da assinatura. |
| `getCertificates()` | `Promise<CertificateDto[]>` | Retorna a lista de certificados A1 e A3 disponíveis na máquina. |
| `sign(params)` | `Promise<SignResult>` | Assinatura desacoplada de digest SHA-256 (recomendado para alta performance). |
| `signPdfDocument(params)` | `Promise<SignPdfDocumentResult>` | Assinatura PAdES completa de PDF com estampa gráfica e posicionamento visual. |
| `signXmlDocument(params)` | `Promise<SignXmlDocumentResult>` | Assinatura XAdES-BES / XMLDSig Enveloped de arquivos XML ou notas fiscais. |
| `authenticate(params)` | `Promise<AuthenticateResult>` | Login seguro sem senha por desafio criptográfico (Challenge-Response). |
| `getSealThemes()` | `Promise<SealThemesResponse>` | Consulta temas de selos gráficos visuais disponíveis no agente. |
| `verify(params)` | `Promise<boolean>` | Validação sintática e integridade da assinatura no client. |
---
## 🔒 Princípios de Segurança e Privacidade
## 📂 Exemplos Prontos
- **Apenas o Digest é transmitido:** O documento completo nunca sai do navegador da aplicação web. O SDK calcula o hash SHA-256 localmente antes de invocar o agente.
- **Isolamento Total:** A comunicação ocorre exclusivamente via loopback em `127.0.0.1:21325`.
- **Autorização Prévia:** Aplicações web desconhecidas devem ser autorizadas visualmente pelo usuário na tela do CyberCore Signer antes de listar certificados.
- **PIN Protegido:** O PIN do token A3 nunca trafega pela web e nunca é enviado a nenhum servidor remoto.
Consulte a pasta [`examples/`](./examples/) para implementações completas:
- [`01-crypto-login.js`](./examples/01-crypto-login.js) — Autenticação sem senha via desafio.
- [`02-sign-digest.js`](./examples/02-sign-digest.js) — Assinatura desacoplada de digest SHA-256.
- [`03-sign-pades.js`](./examples/03-sign-pades.js) — Assinatura de PDF com carimbo visual ICP-Brasil / Gov.br.
- [`04-error-handling.js`](./examples/04-error-handling.js) — Tratamento completo e acionável de erros.
---
## 🔒 Segurança, Conectividade e Protocolo
- **Especificação do Protocolo:** Consulte a documentação formal em [`docs/CCSP.md`](../docs/CCSP.md) e o esquema JSON em [`docs/ccsp-v1.schema.json`](../docs/ccsp-v1.schema.json).
- **Compatibilidade HTTPS e Private Network Access (PNA):** Consulte [`docs/browser-compatibility.md`](../docs/browser-compatibility.md).
- **Processo de Release do Pacote:** Consulte [`docs/sdk-release-process.md`](../docs/sdk-release-process.md).
+68
View File
@@ -0,0 +1,68 @@
/**
* Exemplo 01: Login Seguro via Certificado Digital (Challenge-Response)
*
* Demonstra como autenticar um usuário sem senhas, assinando um desafio emitido
* pelo servidor backend da sua aplicação web.
*/
import { CyberCoreSigner, AgentNotRunningError } from '@cybercore/signer-sdk';
async function main() {
const signer = new CyberCoreSigner({
applicationName: 'Portal Corporativo ERP'
});
// 1. Verifica se o agente desktop está ativo na máquina
const isRunning = await signer.isInstalled();
if (!isRunning) {
console.error('O CyberCore Signer não está em execução. Baixe e abra o aplicativo para continuar.');
return;
}
// 2. Conecta e obtém autorização
console.log('Conectando ao agente...');
await signer.connect();
// 3. Lista os certificados digitais disponíveis
const certs = await signer.getCertificates();
console.log(`Foram localizados ${certs.length} certificado(s) no computador:`);
certs.forEach((c, idx) => {
console.log(`[${idx + 1}] ${c.alias} (${c.certificate_type}) - ${c.subject_name}`);
});
if (certs.length === 0) {
console.warn('Nenhum certificado digital A1 ou token A3 encontrado.');
return;
}
// Seleciona o primeiro certificado disponível
const selectedCert = certs[0];
// 4. Obtém o desafio emitido pelo seu backend (simulado aqui)
const serverChallenge = `auth-session-${Date.now()}-${Math.random().toString(36).substring(2)}`;
console.log(`Desafio recebido do servidor: ${serverChallenge}`);
// 5. Executa a autenticação criptográfica
console.log('Solicitando confirmação de login no CyberCore Signer...');
const authResult = await signer.authenticate({
certificateId: selectedCert.id,
challenge: serverChallenge
});
console.log('Login realizado com sucesso!');
console.log('Titular:', authResult.subjectName);
console.log('CPF/CNPJ:', authResult.subjectCpfCnpj);
console.log('Algoritmo:', authResult.algorithm);
console.log('Assinatura do Desafio (Base64):', authResult.signature.substring(0, 32) + '...');
// O payload retornado deve ser enviado ao seu backend para verificação final:
// POST /api/auth/verify-signature { challenge, signature, certificate }
}
main().catch(err => {
if (err instanceof AgentNotRunningError) {
console.error('Agente offline!\n' + err.actionableGuidance);
} else {
console.error(`Erro no login (${err.code}):`, err.message);
}
});
+59
View File
@@ -0,0 +1,59 @@
/**
* Exemplo 02: Assinatura Desacoplada de Digest (Hash SHA-256)
*
* Demonstra a arquitetura recomendada do CCSP/1:
* O documento NUNCA sai do navegador / servidor da aplicação. Apenas o hash
* de 32 bytes (SHA-256) é enviado ao CyberCore Signer para ser assinado localmente.
*/
import { CyberCoreSigner } from '@cybercore/signer-sdk';
async function main() {
const signer = new CyberCoreSigner({
applicationName: 'Sistema de Prontuário Eletrônico'
});
await signer.connect();
const certs = await signer.getCertificates();
if (certs.length === 0) {
throw new Error('Nenhum certificado disponível.');
}
const selectedCert = certs[0];
// Simula o documento clínico ou contrato gerado na aplicação
const documentoTexto = 'Evolução clínica do paciente João da Silva - 20/10/2026. Procedimento sem intercorrências.';
// 1. Calcula o SHA-256 no próprio ambiente local
const digestBase64 = await signer._calculateSha256(documentoTexto);
console.log('Hash SHA-256 gerado localmente:', digestBase64);
// 2. Envia apenas o digest para o CyberCore Signer
console.log('Aguardando confirmação do usuário no diálogo nativo...');
const result = await signer.sign({
certificateId: selectedCert.id,
digest: digestBase64,
documentName: 'evolucao_clinica_1048.txt',
documentType: 'TXT',
purpose: 'Assinatura de Prontuário Médico',
expiresInSeconds: 120
});
console.log('Documento assinado com sucesso!');
console.log('Status:', result.status);
console.log('Request ID:', result.requestId);
console.log('Algoritmo:', result.algorithm);
console.log('Assinatura (Base64):', result.signature.substring(0, 48) + '...');
console.log('Cadeia X.509 recebida:', result.certificateChain.length, 'certificado(s)');
// 3. Validação preliminar da assinatura no client
const isValid = await signer.verify({
signature: result.signature,
digest: result.digest,
certificate: result.certificate
});
console.log('Assinatura é consistente?', isValid);
}
main().catch(console.error);
+61
View File
@@ -0,0 +1,61 @@
/**
* Exemplo 03: Assinatura de PDF Completo PAdES com Selo Visual
*
* Demonstra como assinar um arquivo PDF aplicando o selo gráfico ICP-Brasil ou Gov.br
* estilizado pelo CyberCore Signer, retornando o PDF assinado completo em Base64/Blob.
*/
import { CyberCoreSigner } from '@cybercore/signer-sdk';
async function main() {
const signer = new CyberCoreSigner({
applicationName: 'Assinador de Contratos Online'
});
await signer.connect();
// 1. Consulta os temas de selos visuais disponíveis no agente
const themesResp = await signer.getSealThemes();
console.log('Selo padrão configurado:', themesResp.default_seal_id);
console.log('Temas disponíveis:', themesResp.seals.map(s => s.name).join(', '));
// 2. Mock de um PDF de teste (no navegador, normalmente é obtido via <input type="file">)
const fakePdfContent = Buffer.from('%PDF-1.4\n1 0 obj\n<< /Type /Catalog >>\nendobj\n%%EOF');
const certs = await signer.getCertificates();
if (certs.length === 0) {
console.error('Nenhum certificado para assinatura.');
return;
}
// 3. Executa a assinatura com estampa gráfica posicionada no rodapé da última página
console.log('Enviando solicitação de assinatura PAdES...');
const result = await signer.signPdfDocument({
pdf: fakePdfContent,
certificateId: certs[0].id,
documentName: 'contrato_prestacao_servicos.pdf',
position: 'BOTTOM_RIGHT',
pageChoice: 'LAST_PAGE',
scale: 1.0,
visibleSeal: true,
sealThemeId: 'icp-brasil',
reason: 'Concordância integral com os termos do contrato',
location: 'Brasília - DF',
cpfDisplayMode: 'MASKED', // Exibe CPF mascarado no carimbo (ex: ***.456.789-**)
includeDateTime: true,
customExtraInfo: 'Matrícula: 55420-9'
});
console.log('PDF assinado com sucesso!');
console.log('Titular:', result.subjectName);
console.log('CPF/CNPJ:', result.subjectCpfCnpj);
console.log('Tamanho do PDF assinado:', result.signedPdf.length, 'caracteres Base64');
// No navegador web, você pode invocar diretamente:
// result.download('contrato_assinado.pdf');
// Ou obter os bytes binários para envio via fetch/multipart:
const pdfBytes = result.getSignedPdfBytes();
console.log('Bytes binários gerados:', pdfBytes.length);
}
main().catch(console.error);
+82
View File
@@ -0,0 +1,82 @@
/**
* Exemplo 04: Tratamento Estruturado e Acionável de Erros
*
* Demonstra como capturar cada erro específico do CCSP/1 e orientar
* o usuário final com mensagens claras e passos práticos de resolução.
*/
import {
CyberCoreSigner,
AgentNotRunningError,
ApplicationNotAuthorizedError,
UserCancelledError,
CertificateNotFoundError,
CertificateExpiredError,
CertificateInvalidError,
PrivateKeyNotAvailableError,
PinRequiredError,
PinIncorrectError,
TokenNotFoundError,
RequestExpiredError,
TimeoutError,
CyberCoreError
} from '@cybercore/signer-sdk';
async function performSigningOperation(signer, certId, fileDigest) {
try {
return await signer.sign({
certificateId: certId,
digest: fileDigest,
documentName: 'termo_responsabilidade.pdf'
});
} catch (err) {
if (err instanceof AgentNotRunningError) {
// O agente local está fechado ou bloqueado
console.warn('⚠️ AGENTE NÃO DETECTADO:');
console.warn(err.actionableGuidance);
// Sugestão de UI: exibir banner com botão "Baixar CyberCore Signer"
} else if (err instanceof ApplicationNotAuthorizedError) {
// O usuário clicou em "Rejeitar" no prompt de autorização da aplicação
console.warn('⚠️ APLICAÇÃO NÃO AUTORIZADA:');
console.warn('O usuário não autorizou este portal no diálogo de segurança do CyberCore Signer.');
} else if (err instanceof UserCancelledError) {
// O usuário cancelou a assinatura explicitamente na janela nativa
console.info('ℹ️ Assinatura cancelada pelo usuário.');
} else if (err instanceof CertificateExpiredError) {
console.error('❌ CERTIFICADO EXPIRADO: O certificado digital selecionado expirou.');
} else if (err instanceof CertificateInvalidError) {
console.error('❌ CERTIFICADO INVÁLIDO: O certificado possui estrutura corrompida ou foi revogado.');
} else if (err instanceof PrivateKeyNotAvailableError) {
console.error('❌ CHAVE PRIVADA INDISPONÍVEL: A chave privada deste certificado não está acessível no computador.');
} else if (err instanceof PinIncorrectError) {
console.error('❌ PIN INCORRETO: O PIN digitado para o token A3 está errado. Cuidado com o bloqueio do dispositivo.');
} else if (err instanceof PinRequiredError) {
console.error('❌ PIN OBRIGATÓRIO: É necessário fornecer o PIN do dispositivo criptográfico.');
} else if (err instanceof TokenNotFoundError) {
console.error('❌ TOKEN NÃO ENCONTRADO: O token USB ou smart card foi removido da porta USB.');
} else if (err instanceof RequestExpiredError) {
console.warn('⚠️ REQUISIÇÃO EXPIRADA: O tempo limite para confirmação do usuário esgotou.');
} else if (err instanceof TimeoutError) {
console.error('⏱️ TIMEOUT: O agente demorou mais tempo que o esperado para responder.');
} else if (err instanceof CyberCoreError) {
console.error(`⚠️ ERRO DO PROTOCOLO CCSP (${err.code}):`, err.message);
} else {
console.error('Erro desconhecido:', err);
}
throw err;
}
}
async function main() {
const signer = new CyberCoreSigner({ timeoutMs: 15000 });
const fakeDigest = Buffer.alloc(32, 9).toString('base64');
console.log('Testando tratamento de erros com certificado simulado expirado...');
try {
await performSigningOperation(signer, 'cert-expirado', fakeDigest);
} catch {
console.log('Tratamento de exceções concluído com sucesso.');
}
}
main().catch(() => {});
+75
View File
@@ -0,0 +1,75 @@
# Exemplos de Integração — CyberCore Signer Web SDK
Este diretório contém exemplos práticos e prontos para uso demonstrando os principais fluxos de integração do `@cybercore/signer-sdk` com aplicações web.
---
## 📁 Arquivos de Exemplo
| Arquivo | Descrição |
| :--- | :--- |
| [`01-crypto-login.js`](./01-crypto-login.js) | Autenticação segura por desafio criptográfico (Challenge-Response) sem senhas. |
| [`02-sign-digest.js`](./02-sign-digest.js) | Assinatura desacoplada em que o navegador calcula o hash SHA-256 e o CyberCore assina apenas o digest. |
| [`03-sign-pades.js`](./03-sign-pades.js) | Assinatura digital completa de PDF (PAdES) com selo gráfico posicionado (ICP-Brasil / Gov.br). |
| [`04-error-handling.js`](./04-error-handling.js) | Captura e tratamento estruturado de todos os erros do protocolo CCSP/1 com guia acionável. |
---
## 🚀 Como Executar
### Pré-requisitos
1. Agente desktop **CyberCore Signer** em execução na máquina (`127.0.0.1:21325`).
2. Node.js 18+ instalado.
### Execução direta via Node.js
Como o pacote usa módulos ES (`"type": "module"`), você pode testar diretamente com:
```bash
# Executar exemplo de login
node examples/01-crypto-login.js
# Executar exemplo de assinatura de digest
node examples/02-sign-digest.js
# Executar exemplo de assinatura PAdES com selo
node examples/03-sign-pades.js
# Executar exemplo de tratamento de erros
node examples/04-error-handling.js
```
---
## 💡 Uso em Projetos Frontend (React, Vue, Angular, Svelte, Vanilla)
Instale o pacote a partir do seu registry npm privado:
```bash
npm install @cybercore/signer-sdk
```
Importe os métodos e tipos em qualquer componente ou serviço web:
```typescript
import { CyberCoreSigner, AgentNotRunningError } from '@cybercore/signer-sdk';
const signer = new CyberCoreSigner({
applicationName: 'Meu Sistema Web'
});
async function assinarDocumento(arquivoPdf: File, certId: string) {
try {
const resultado = await signer.signPdfDocument({
pdf: arquivoPdf,
certificateId: certId,
position: 'BOTTOM_RIGHT',
sealThemeId: 'icp-brasil'
});
// Baixa o documento assinado no navegador
resultado.download('documento_assinado.pdf');
} catch (err) {
if (err instanceof AgentNotRunningError) {
alert(err.actionableGuidance);
}
}
}
```
+30 -4
View File
@@ -1,19 +1,45 @@
{
"name": "@cybercore/signer-sdk",
"version": "0.1.0",
"description": "SDK JavaScript para integração de aplicações web com o agente desktop CyberCore Signer via protocolo CCSP/1",
"description": "SDK JavaScript/TypeScript para integração de aplicações web com o agente desktop CyberCore Signer via protocolo CCSP/1",
"type": "module",
"main": "src/signer-sdk.js",
"module": "src/signer-sdk.js",
"scripts": {
"test": "node --test test/signer-sdk.test.js"
"types": "src/signer-sdk.d.ts",
"exports": {
".": {
"types": "./src/signer-sdk.d.ts",
"import": "./src/signer-sdk.js",
"default": "./src/signer-sdk.js"
},
"author": "CyberCore",
"./errors": {
"types": "./src/errors.d.ts",
"import": "./src/errors.js",
"default": "./src/errors.js"
}
},
"files": [
"src",
"README.md"
],
"engines": {
"node": ">=18.0.0"
},
"sideEffects": false,
"publishConfig": {
"access": "restricted"
},
"scripts": {
"test": "node --test test/*.test.js"
},
"author": "CyberCore Systems",
"license": "Proprietary",
"keywords": [
"cybercore",
"digital-signature",
"icp-brasil",
"pades",
"xmldsig",
"a1",
"a3",
"token",
+114
View File
@@ -0,0 +1,114 @@
/**
* Definições TypeScript para os erros estruturados do protocolo CCSP/1 (@cybercore/signer-sdk).
*/
export type CcspErrorCode =
| 'AGENT_NOT_RUNNING'
| 'COMMUNICATION_ERROR'
| 'APPLICATION_NOT_AUTHORIZED'
| 'USER_CANCELLED'
| 'CERTIFICATE_NOT_FOUND'
| 'CERTIFICATE_EXPIRED'
| 'CERTIFICATE_INVALID'
| 'PRIVATE_KEY_NOT_AVAILABLE'
| 'PIN_REQUIRED'
| 'PIN_INCORRECT'
| 'TOKEN_NOT_FOUND'
| 'TOKEN_REMOVED'
| 'PKCS11_ERROR'
| 'REQUEST_EXPIRED'
| 'REPLAY_DETECTED'
| 'INVALID_ORIGIN'
| 'UNSUPPORTED_ALGORITHM'
| 'PROVIDER_NOT_AVAILABLE'
| 'SIGNATURE_FAILED'
| 'TIMEOUT_ERROR'
| 'BAD_REQUEST'
| 'SIGN_ERROR'
| 'SIGN_PDF_ERROR'
| 'SIGN_XML_ERROR'
| 'AUTH_ERROR'
| 'CONNECTION_FAILED'
| string;
export class CyberCoreError extends Error {
code: CcspErrorCode;
originalError?: unknown;
constructor(message: string, code?: CcspErrorCode, originalError?: unknown);
}
export interface AgentNotRunningOptions {
host?: string;
port?: number;
}
export class AgentNotRunningError extends CyberCoreError {
host: string;
port: number;
actionableGuidance: string;
constructor(message?: string, options?: AgentNotRunningOptions);
}
export class ApplicationNotAuthorizedError extends CyberCoreError {
constructor(message?: string);
}
export class UserCancelledError extends CyberCoreError {
constructor(message?: string);
}
export class CertificateNotFoundError extends CyberCoreError {
constructor(message?: string);
}
export class CertificateExpiredError extends CyberCoreError {
constructor(message?: string);
}
export class CertificateInvalidError extends CyberCoreError {
constructor(message?: string);
}
export class PrivateKeyNotAvailableError extends CyberCoreError {
constructor(message?: string);
}
export class PinRequiredError extends CyberCoreError {
constructor(message?: string);
}
export class PinIncorrectError extends CyberCoreError {
constructor(message?: string);
}
export class TokenNotFoundError extends CyberCoreError {
constructor(message?: string);
}
export class RequestExpiredError extends CyberCoreError {
constructor(message?: string);
}
export class ReplayDetectedError extends CyberCoreError {
constructor(message?: string);
}
export class InvalidOriginError extends CyberCoreError {
constructor(message?: string);
}
export class UnsupportedAlgorithmError extends CyberCoreError {
constructor(message?: string);
}
export class ProviderNotAvailableError extends CyberCoreError {
constructor(message?: string);
}
export class SignatureFailedError extends CyberCoreError {
constructor(message?: string);
}
export class TimeoutError extends CyberCoreError {
constructor(message?: string);
}
+72 -6
View File
@@ -12,9 +12,19 @@ export class CyberCoreError extends Error {
}
export class AgentNotRunningError extends CyberCoreError {
constructor(message = 'O agente desktop CyberCore Signer não está em execução ou não foi detectado em 127.0.0.1:21325.') {
constructor(
message = 'O agente desktop CyberCore Signer não está em execução ou não foi detectado em 127.0.0.1:21325. Certifique-se de que o aplicativo está instalado e aberto na bandeja do sistema (system tray).',
options = {}
) {
super(message, 'AGENT_NOT_RUNNING');
this.name = 'AgentNotRunningError';
this.host = options.host || '127.0.0.1';
this.port = options.port || 21325;
this.actionableGuidance = [
'1. Verifique se o ícone do CyberCore Signer está ativo na barra de tarefas / bandeja do sistema.',
'2. Se o aplicativo ainda não estiver instalado, baixe e execute o instalador oficial do CyberCore Signer.',
'3. Certifique-se de que a porta local 21325 não está bloqueada por firewall ou softwares antivírus.'
].join('\n');
}
}
@@ -26,21 +36,42 @@ export class ApplicationNotAuthorizedError extends CyberCoreError {
}
export class UserCancelledError extends CyberCoreError {
constructor(message = 'A operação de assinatura foi cancelada pelo usuário.') {
constructor(message = 'A operação de assinatura foi cancelada pelo usuário no diálogo nativo.') {
super(message, 'USER_CANCELLED');
this.name = 'UserCancelledError';
}
}
export class CertificateNotFoundError extends CyberCoreError {
constructor(message = 'Certificado digital não encontrado ou não selecionado.') {
constructor(message = 'Certificado digital não encontrado ou não selecionado no agente.') {
super(message, 'CERTIFICATE_NOT_FOUND');
this.name = 'CertificateNotFoundError';
}
}
export class CertificateExpiredError extends CyberCoreError {
constructor(message = 'O certificado digital selecionado expirou e não pode ser utilizado para assinatura.') {
super(message, 'CERTIFICATE_EXPIRED');
this.name = 'CertificateExpiredError';
}
}
export class CertificateInvalidError extends CyberCoreError {
constructor(message = 'O certificado digital é inválido, foi revogado ou possui estrutura X.509 corrompida.') {
super(message, 'CERTIFICATE_INVALID');
this.name = 'CertificateInvalidError';
}
}
export class PrivateKeyNotAvailableError extends CyberCoreError {
constructor(message = 'A chave privada do certificado não está acessível no computador ou o dispositivo físico está ausente.') {
super(message, 'PRIVATE_KEY_NOT_AVAILABLE');
this.name = 'PrivateKeyNotAvailableError';
}
}
export class PinRequiredError extends CyberCoreError {
constructor(message = 'PIN do token ou smart card A3 é obrigatório.') {
constructor(message = 'O PIN do token USB ou smart card A3 é obrigatório para autorizar a chave privada.') {
super(message, 'PIN_REQUIRED');
this.name = 'PinRequiredError';
}
@@ -54,14 +85,14 @@ export class PinIncorrectError extends CyberCoreError {
}
export class TokenNotFoundError extends CyberCoreError {
constructor(message = 'Token USB ou smart card não foi detectado no computador.') {
constructor(message = 'Token USB ou smart card não foi detectado no computador ou foi removido.') {
super(message, 'TOKEN_NOT_FOUND');
this.name = 'TokenNotFoundError';
}
}
export class RequestExpiredError extends CyberCoreError {
constructor(message = 'A solicitação de assinatura expirou.') {
constructor(message = 'A solicitação de assinatura expirou antes de ser processada.') {
super(message, 'REQUEST_EXPIRED');
this.name = 'RequestExpiredError';
}
@@ -73,3 +104,38 @@ export class ReplayDetectedError extends CyberCoreError {
this.name = 'ReplayDetectedError';
}
}
export class InvalidOriginError extends CyberCoreError {
constructor(message = 'A origem web solicitante não confere com os domínios homologados para esta aplicação.') {
super(message, 'INVALID_ORIGIN');
this.name = 'InvalidOriginError';
}
}
export class UnsupportedAlgorithmError extends CyberCoreError {
constructor(message = 'O algoritmo criptográfico solicitado não é suportado pelo provedor de certificados.') {
super(message, 'UNSUPPORTED_ALGORITHM');
this.name = 'UnsupportedAlgorithmError';
}
}
export class ProviderNotAvailableError extends CyberCoreError {
constructor(message = 'O provedor criptográfico necessário não está disponível no sistema operacional.') {
super(message, 'PROVIDER_NOT_AVAILABLE');
this.name = 'ProviderNotAvailableError';
}
}
export class SignatureFailedError extends CyberCoreError {
constructor(message = 'Falha durante o cálculo da assinatura digital.') {
super(message, 'SIGNATURE_FAILED');
this.name = 'SignatureFailedError';
}
}
export class TimeoutError extends CyberCoreError {
constructor(message = 'Tempo limite esgotado na comunicação com o agente local.') {
super(message, 'TIMEOUT_ERROR');
this.name = 'TimeoutError';
}
}
+2
View File
@@ -0,0 +1,2 @@
export * from './signer-sdk.js';
export { default } from './signer-sdk.js';
+246
View File
@@ -0,0 +1,246 @@
/**
* CyberCore Signer Web SDK (@cybercore/signer-sdk)
* Protocolo: CCSP/1 (CyberCore Signing Protocol v1)
*/
export * from './errors.js';
export interface CyberCoreSignerOptions {
/** URL base completa do agente local (prevalece sobre host e port). Ex: 'http://127.0.0.1:21325' */
baseUrl?: string;
/** Host do agente local (padrão: '127.0.0.1') */
host?: string;
/** Porta HTTP do agente local (padrão: 21325) */
port?: number;
/** Timeout padrão para requisições em milissegundos (padrão: 30000) */
timeoutMs?: number;
/** UUID persistido da aplicação web */
applicationId?: string;
/** Nome legível da aplicação exibido na confirmação de assinatura */
applicationName?: string;
}
export interface StatusResponse {
version: string;
status: string;
agent_version?: string;
os?: string;
ready?: boolean;
[key: string]: unknown;
}
export interface VersionInfo {
protocolVersion: string;
agentVersion: string;
os: string;
}
export interface CapabilitiesResponse {
protocolVersion: string;
supportedAlgorithms: string[];
supportedHashAlgorithms: string[];
certificateTypes: ('A1' | 'A3')[];
hardwareSupport: string[];
padesSupport: boolean;
[key: string]: unknown;
}
export interface CertificateDto {
id: string;
alias?: string;
subject_name: string;
cpf_cnpj?: string;
issuer_name?: string;
serial_number?: string;
valid_from?: string;
valid_to?: string;
certificate_type: 'A1' | 'A3';
has_private_key: boolean;
key_algorithm?: string;
provider_type?: string;
[key: string]: unknown;
}
export interface SignParams {
certificateId?: string;
certificateHint?: string;
digest?: string;
data?: string | Uint8Array | ArrayBuffer;
algorithm?: 'SHA256withRSA' | 'SHA1withRSA' | string;
operation?: 'sign' | string;
documentName?: string;
fileName?: string;
documentType?: string;
fileType?: string;
purpose?: string;
expiresInSeconds?: number;
}
export interface SignResult {
requestId: string;
status: 'signed' | 'authenticated';
signature: string;
certificate: string;
certificateChain: string[];
algorithm: string;
digest: string;
subjectName: string | null;
subjectCpfCnpj: string | null;
documentName: string | null;
documentType: string | null;
}
export interface SignPdfDocumentParams {
pdf: string | File | Blob | ArrayBuffer | Uint8Array;
certificateId?: string;
certificateHint?: string;
documentName?: string;
fileName?: string;
page?: number;
pageChoice?: 'LAST_PAGE' | 'FIRST_PAGE' | null;
position?: 'BOTTOM_RIGHT' | 'BOTTOM_LEFT' | 'TOP_RIGHT' | 'TOP_LEFT' | 'CUSTOM' | string;
x?: number;
y?: number;
scale?: number;
visibleSeal?: boolean;
sealThemeId?: string;
sealCustom?: Record<string, unknown>;
reason?: string;
location?: string;
cpfDisplayMode?: 'FULL' | 'MASKED' | 'NONE';
includeDateTime?: boolean;
customExtraInfo?: string;
expiresInSeconds?: number;
}
export interface SignPdfDocumentResult {
requestId: string;
status: 'signed';
signedPdf: string;
signed_pdf: string;
sha256?: string;
signature?: string;
certificate: string;
certificateChain: string[];
algorithm: string;
subjectName: string | null;
subjectCpfCnpj: string | null;
documentName: string;
getSignedPdfBlob(): Blob;
getSignedPdfBytes(): Uint8Array;
download(filename?: string): void;
}
export interface SignXmlDocumentParams {
xml: string | File | Blob | ArrayBuffer;
certificateId?: string;
certificateHint?: string;
profile?: 'XADES_BES' | 'XMLDSIG' | string;
targetElementTag?: string;
targetElementId?: string;
documentName?: string;
fileName?: string;
algorithm?: string;
expiresInSeconds?: number;
}
export interface SignXmlDocumentResult {
requestId: string;
status: 'signed';
signedXml: string;
sha256?: string;
signature?: string;
certificate: string;
certificateChain: string[];
algorithm: string;
subjectName: string | null;
subjectCpfCnpj: string | null;
documentName: string;
getSignedXmlBlob(): Blob;
getSignedXmlBytes(): Uint8Array;
download(filename?: string): void;
}
export interface AuthenticateParams {
certificateId?: string;
certificateHint?: string;
challenge?: string;
algorithm?: string;
expiresInSeconds?: number;
}
export interface AuthenticateResult {
requestId: string;
status: 'authenticated';
signature: string;
certificate: string;
certificateChain: string[];
algorithm: string;
challenge: string;
subjectName: string;
subjectCpfCnpj: string;
}
export interface VerifyParams {
signature: string;
digest: string;
certificate: string;
}
export interface SealThemesResponse {
version: string;
status: string;
default_seal_id?: string;
seals: Array<{ id: string; name: string; [key: string]: unknown }>;
}
export interface SubjectInfo {
name: string;
cpfCnpj: string;
}
export class CyberCoreSigner {
baseUrl: string;
host: string;
port: number;
timeoutMs: number;
applicationId: string;
applicationName: string;
connected: boolean;
constructor(options?: CyberCoreSignerOptions);
isInstalled(timeoutMs?: number): Promise<boolean>;
getStatus(timeoutMs?: number): Promise<StatusResponse>;
getVersion(): Promise<VersionInfo>;
getCapabilities(): Promise<CapabilitiesResponse>;
connect(): Promise<boolean>;
getCertificates(): Promise<CertificateDto[]>;
sign(params: SignParams): Promise<SignResult>;
signFile(file: File | Blob | ArrayBuffer | Uint8Array, options?: Partial<SignParams>): Promise<SignResult>;
signPdf(pdfFile: File | Blob | ArrayBuffer | Uint8Array, options?: Partial<SignParams>): Promise<SignResult>;
signXml(xmlContent: string | File | Blob | ArrayBuffer, options?: Partial<SignParams>): Promise<SignResult>;
getSealThemes(): Promise<SealThemesResponse>;
signPdfDocument(params: SignPdfDocumentParams): Promise<SignPdfDocumentResult>;
signXmlDocument(params: SignXmlDocumentParams): Promise<SignXmlDocumentResult>;
authenticate(options?: AuthenticateParams): Promise<AuthenticateResult>;
login(options?: AuthenticateParams): Promise<AuthenticateResult>;
extractSubjectInfo(certificateBase64: string): SubjectInfo;
verify(params: VerifyParams): Promise<boolean>;
_fetch(endpoint: string, options?: Record<string, unknown>): Promise<any>;
_calculateSha256(data: string | ArrayBuffer | ArrayBufferView): Promise<string>;
_throwMappedError(code: string, message?: string): never;
}
export class PdfStampPicker {
constructor(options?: unknown);
open(options?: unknown): Promise<unknown>;
}
export class PdfSealDesigner {
constructor(options?: unknown);
open(options?: unknown): Promise<unknown>;
}
export default CyberCoreSigner;
+75 -11
View File
@@ -9,29 +9,66 @@ import {
ApplicationNotAuthorizedError,
UserCancelledError,
CertificateNotFoundError,
CertificateExpiredError,
CertificateInvalidError,
PrivateKeyNotAvailableError,
PinRequiredError,
PinIncorrectError,
TokenNotFoundError,
RequestExpiredError,
ReplayDetectedError
ReplayDetectedError,
InvalidOriginError,
UnsupportedAlgorithmError,
ProviderNotAvailableError,
SignatureFailedError,
TimeoutError
} from './errors.js';
import { PdfStampPicker } from './pdf-stamp-picker.js';
import { PdfSealDesigner } from './pdf-seal-designer.js';
export { PdfStampPicker, PdfSealDesigner };
export {
PdfStampPicker,
PdfSealDesigner,
CyberCoreError,
AgentNotRunningError,
ApplicationNotAuthorizedError,
UserCancelledError,
CertificateNotFoundError,
CertificateExpiredError,
CertificateInvalidError,
PrivateKeyNotAvailableError,
PinRequiredError,
PinIncorrectError,
TokenNotFoundError,
RequestExpiredError,
ReplayDetectedError,
InvalidOriginError,
UnsupportedAlgorithmError,
ProviderNotAvailableError,
SignatureFailedError,
TimeoutError
};
export class CyberCoreSigner {
/**
* Inicializa o cliente do SDK.
*
* @param {Object} options Configurações do SDK.
* @param {string} [options.baseUrl='http://127.0.0.1:21325'] URL base do agente local.
* @param {string} [options.applicationId] UUID único da aplicação web (gerado se não fornecido).
* @param {Object} [options={}] Configurações do SDK.
* @param {string} [options.baseUrl] URL base completa do agente local (prevalece sobre host e port).
* @param {string} [options.host='127.0.0.1'] Host do agente local.
* @param {number} [options.port=21325] Porta HTTP do agente local.
* @param {number} [options.timeoutMs=30000] Timeout padrão das operações em milissegundos.
* @param {string} [options.applicationId] UUID único da aplicação web (gerado e persistido se omitido).
* @param {string} [options.applicationName] Nome legível da aplicação apresentado ao usuário.
*/
constructor(options = {}) {
this.baseUrl = (options.baseUrl || 'http://127.0.0.1:21325').replace(/\/+$/, '');
const host = options.host || '127.0.0.1';
const port = options.port !== undefined ? options.port : 21325;
this.host = host;
this.port = port;
this.baseUrl = (options.baseUrl || `http://${host}:${port}`).replace(/\/+$/, '');
this.timeoutMs = options.timeoutMs !== undefined ? options.timeoutMs : 30000;
this.applicationId = options.applicationId || this._getOrCreateApplicationId();
this.applicationName = options.applicationName || (typeof document !== 'undefined' ? document.title : 'Aplicação Web');
this.connected = false;
@@ -63,8 +100,15 @@ export class CyberCoreSigner {
const resp = await this._fetch('/ccsp/v1/status', { method: 'GET', timeoutMs });
return resp;
} catch (err) {
if (err.name === 'AbortError' || err.message?.includes('Failed to fetch') || err.message?.includes('NetworkError')) {
throw new AgentNotRunningError();
if (
err.name === 'AbortError' ||
err instanceof TimeoutError ||
err instanceof AgentNotRunningError ||
err.message?.includes('Failed to fetch') ||
err.message?.includes('NetworkError') ||
err.code === 'ECONNREFUSED'
) {
throw new AgentNotRunningError(undefined, { host: this.host, port: this.port });
}
throw err;
}
@@ -702,7 +746,7 @@ export class CyberCoreSigner {
*/
async _fetch(endpoint, options = {}) {
const url = `${this.baseUrl}${endpoint}`;
const timeout = options.timeoutMs || 15000;
const timeout = options.timeoutMs !== undefined ? options.timeoutMs : (this.timeoutMs || 30000);
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
const timeoutId = controller ? setTimeout(() => controller.abort(), timeout) : null;
@@ -738,12 +782,15 @@ export class CyberCoreSigner {
return data;
} catch (err) {
if (err.name === 'AbortError') {
throw new CyberCoreError(`Tempo limite esgotado (${timeout}ms) na comunicação com o agente.`, 'TIMEOUT_ERROR');
throw new TimeoutError(`Tempo limite esgotado (${timeout}ms) na comunicação com o agente local.`);
}
if (err instanceof CyberCoreError) {
throw err;
}
throw new AgentNotRunningError(`Falha ao conectar com o CyberCore Signer em ${this.baseUrl}: ${err.message}`);
throw new AgentNotRunningError(
`Falha ao conectar com o CyberCore Signer em ${this.baseUrl}: ${err.message}`,
{ host: this.host, port: this.port }
);
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
@@ -829,6 +876,12 @@ export class CyberCoreSigner {
throw new UserCancelledError(message);
case 'CERTIFICATE_NOT_FOUND':
throw new CertificateNotFoundError(message);
case 'CERTIFICATE_EXPIRED':
throw new CertificateExpiredError(message);
case 'CERTIFICATE_INVALID':
throw new CertificateInvalidError(message);
case 'PRIVATE_KEY_NOT_AVAILABLE':
throw new PrivateKeyNotAvailableError(message);
case 'APPLICATION_NOT_AUTHORIZED':
throw new ApplicationNotAuthorizedError(message);
case 'PIN_REQUIRED':
@@ -836,11 +889,22 @@ export class CyberCoreSigner {
case 'PIN_INCORRECT':
throw new PinIncorrectError(message);
case 'TOKEN_NOT_FOUND':
case 'TOKEN_REMOVED':
throw new TokenNotFoundError(message);
case 'REQUEST_EXPIRED':
throw new RequestExpiredError(message);
case 'REPLAY_DETECTED':
throw new ReplayDetectedError(message);
case 'INVALID_ORIGIN':
throw new InvalidOriginError(message);
case 'UNSUPPORTED_ALGORITHM':
throw new UnsupportedAlgorithmError(message);
case 'PROVIDER_NOT_AVAILABLE':
throw new ProviderNotAvailableError(message);
case 'SIGNATURE_FAILED':
throw new SignatureFailedError(message);
case 'TIMEOUT_ERROR':
throw new TimeoutError(message);
default:
throw new CyberCoreError(message || `Erro do protocolo: ${code}`, code);
}
+256
View File
@@ -0,0 +1,256 @@
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import {
CyberCoreSigner,
AgentNotRunningError,
ApplicationNotAuthorizedError,
CertificateExpiredError,
UserCancelledError,
TimeoutError
} from '../src/signer-sdk.js';
describe('@cybercore/signer-sdk - Testes de Integração com Mock HTTP Server', () => {
let server;
let serverPort;
let baseUrl;
// Servidor Mock HTTP emulando o protocolo CCSP/1
before(async () => {
server = http.createServer((req, res) => {
// CORS & Preflight
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-CyberCore-AppId');
res.setHeader('Access-Control-Allow-Private-Network', 'true');
if (req.method === 'OPTIONS') {
res.writeHead(204);
res.end();
return;
}
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', () => {
const url = new URL(req.url, `http://127.0.0.1:${serverPort}`);
if (url.pathname === '/ccsp/v1/status') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
version: '1',
status: 'ready',
agent_version: '0.1.0',
os: 'Linux x86_64'
}));
return;
}
if (url.pathname === '/ccsp/v1/delay') {
// Endpoint propositalmente lento para testar timeout
setTimeout(() => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'slow_response' }));
}, 300);
return;
}
if (url.pathname === '/ccsp/v1/authorize') {
const payload = JSON.parse(body || '{}');
if (payload.application_id === 'blocked-app') {
res.writeHead(403, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
version: '1',
status: 'rejected',
error_code: 'APPLICATION_NOT_AUTHORIZED',
message: 'Aplicação bloqueada pelo usuário.'
}));
return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
version: '1',
status: 'authorized',
application_id: payload.application_id
}));
return;
}
if (url.pathname === '/ccsp/v1/certificates') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
version: '1',
status: 'success',
certificates: [
{
id: 'cert-a1-1',
alias: 'Certificado A1 Teste',
subject_name: 'JOAO DA SILVA:12345678900',
certificate_type: 'A1',
has_private_key: true
}
]
}));
return;
}
if (url.pathname === '/ccsp/v1/sign') {
const payload = JSON.parse(body || '{}');
if (payload.certificate_hint === 'expired-cert') {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
version: '1',
request_id: payload.request_id,
status: 'error',
error_code: 'CERTIFICATE_EXPIRED',
message: 'Certificado expirou em 2025.'
}));
return;
}
if (payload.certificate_hint === 'cancel-cert') {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
version: '1',
request_id: payload.request_id,
status: 'error',
error_code: 'USER_CANCELLED',
message: 'Assinatura cancelada no diálogo.'
}));
return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
version: '1',
request_id: payload.request_id,
status: 'signed',
signature: 'MOCK_SIGNATURE_BASE64',
certificate: 'MOCK_CERT_BASE64',
algorithm: payload.algorithm || 'SHA256withRSA',
document_name: payload.document_name
}));
return;
}
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error_code: 'NOT_FOUND', message: 'Endpoint não encontrado' }));
});
});
await new Promise((resolve, reject) => {
server.listen(0, '127.0.0.1', () => {
serverPort = server.address().port;
baseUrl = `http://127.0.0.1:${serverPort}`;
resolve();
});
server.on('error', reject);
});
});
after(async () => {
if (server) {
await new Promise(resolve => server.close(resolve));
}
});
it('deve confirmar que o agente está ativo com isInstalled()', async () => {
const signer = new CyberCoreSigner({ baseUrl });
const installed = await signer.isInstalled();
assert.equal(installed, true);
});
it('deve retornar dados de status corretos em getStatus()', async () => {
const signer = new CyberCoreSigner({ baseUrl });
const status = await signer.getStatus();
assert.equal(status.version, '1');
assert.equal(status.status, 'ready');
assert.equal(status.agent_version, '0.1.0');
});
it('deve realizar handshake com connect() com sucesso', async () => {
const signer = new CyberCoreSigner({ baseUrl, applicationName: 'ERP Teste' });
const connected = await signer.connect();
assert.equal(connected, true);
assert.equal(signer.connected, true);
});
it('deve lançar ApplicationNotAuthorizedError se o agente rejeitar autorização', async () => {
const signer = new CyberCoreSigner({ baseUrl, applicationId: 'blocked-app' });
await assert.rejects(
async () => signer.connect(),
ApplicationNotAuthorizedError
);
});
it('deve listar certificados disponíveis em getCertificates()', async () => {
const signer = new CyberCoreSigner({ baseUrl });
const certs = await signer.getCertificates();
assert.ok(Array.isArray(certs));
assert.equal(certs.length, 1);
assert.equal(certs[0].id, 'cert-a1-1');
});
it('deve assinar com sucesso via sign()', async () => {
const signer = new CyberCoreSigner({ baseUrl });
const result = await signer.sign({
certificateId: 'cert-a1-1',
digest: Buffer.alloc(32, 7).toString('base64'),
documentName: 'relatorio.pdf'
});
assert.equal(result.status, 'signed');
assert.equal(result.signature, 'MOCK_SIGNATURE_BASE64');
assert.equal(result.documentName, 'relatorio.pdf');
});
it('deve tratar erro CERTIFICATE_EXPIRED lançando CertificateExpiredError', async () => {
const signer = new CyberCoreSigner({ baseUrl });
await assert.rejects(
async () => signer.sign({
certificateId: 'expired-cert',
digest: Buffer.alloc(32, 1).toString('base64')
}),
CertificateExpiredError
);
});
it('deve tratar cancelamento de usuário lançando UserCancelledError', async () => {
const signer = new CyberCoreSigner({ baseUrl });
await assert.rejects(
async () => signer.sign({
certificateId: 'cancel-cert',
digest: Buffer.alloc(32, 2).toString('base64')
}),
UserCancelledError
);
});
it('deve lançar TimeoutError quando a chamada exceder o timeoutMs configurado', async () => {
const signer = new CyberCoreSigner({ baseUrl, timeoutMs: 50 });
await assert.rejects(
async () => signer._fetch('/ccsp/v1/delay'),
TimeoutError
);
});
it('deve lançar AgentNotRunningError com mensagem acionável se porta estiver inativa', async () => {
// Porta inativa aleatória
const deadPort = 64999;
const signer = new CyberCoreSigner({ host: '127.0.0.1', port: deadPort });
const installed = await signer.isInstalled(300);
assert.equal(installed, false);
await assert.rejects(
async () => signer.getStatus(300),
err => {
assert.ok(err instanceof AgentNotRunningError);
assert.equal(err.code, 'AGENT_NOT_RUNNING');
assert.equal(err.port, deadPort);
assert.ok(err.actionableGuidance.includes('bandeja do sistema'));
return true;
}
);
});
});
+245
View File
@@ -0,0 +1,245 @@
import { describe, it, before } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { CyberCoreSigner } from '../src/signer-sdk.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
describe('@cybercore/signer-sdk - Validação Cruzada de Payloads do SDK contra JSON Schema CCSP/1', () => {
let schema;
before(() => {
const schemaPath = path.resolve(__dirname, '../../docs/ccsp-v1.schema.json');
assert.ok(fs.existsSync(schemaPath), `Arquivo de schema não encontrado em: ${schemaPath}`);
schema = JSON.parse(fs.readFileSync(schemaPath, 'utf8'));
});
function validatePayloadAgainstDefinition(payload, definitionName) {
const def = schema.definitions?.[definitionName];
assert.ok(def, `Definição '${definitionName}' deve existir no JSON Schema.`);
// 1. Verifica campos obrigatórios declarados em 'required'
if (Array.isArray(def.required)) {
for (const field of def.required) {
assert.ok(
payload[field] !== undefined && payload[field] !== null,
`Payload para '${definitionName}' não contém o campo obrigatório '${field}'`
);
}
}
// 2. Verifica tipos das propriedades declaradas
if (def.properties) {
for (const [key, value] of Object.entries(payload)) {
if (value === null || value === undefined) continue;
const propDef = def.properties[key];
if (!propDef) continue;
const expectedType = propDef.type;
if (typeof expectedType === 'string') {
assertTypeMatches(value, expectedType, key, definitionName);
} else if (Array.isArray(expectedType)) {
// Tipos múltiplos (ex: ["string", "null"])
const matchesAny = expectedType.some(t => checkType(value, t));
assert.ok(matchesAny, `Campo '${key}' em '${definitionName}' não corresponde a nenhum dos tipos permitidos: ${expectedType.join(', ')}`);
}
// Validação de enums
if (Array.isArray(propDef.enum)) {
assert.ok(
propDef.enum.includes(value),
`Campo '${key}' em '${definitionName}' possui valor '${value}' fora do enum permitido: [${propDef.enum.join(', ')}]`
);
}
}
}
}
function checkType(value, expectedType) {
switch (expectedType) {
case 'string': return typeof value === 'string';
case 'boolean': return typeof value === 'boolean';
case 'number': return typeof value === 'number';
case 'integer': return Number.isInteger(value);
case 'array': return Array.isArray(value);
case 'object': return typeof value === 'object' && value !== null && !Array.isArray(value);
case 'null': return value === null;
default: return true;
}
}
function assertTypeMatches(value, expectedType, propName, defName) {
assert.ok(
checkType(value, expectedType),
`Campo '${propName}' em '${defName}' deve ser do tipo '${expectedType}', recebido: ${typeof value}`
);
}
it('deve gerar payload válido para AuthorizeRequest no connect()', async () => {
const signer = new CyberCoreSigner({ applicationName: 'App Teste Schema' });
let capturedPayload = null;
signer._fetch = async (endpoint, options) => {
if (endpoint === '/ccsp/v1/authorize') {
capturedPayload = JSON.parse(options.body);
return { version: '1', status: 'authorized', application_id: capturedPayload.application_id };
}
return {};
};
await signer.connect();
assert.ok(capturedPayload);
validatePayloadAgainstDefinition(capturedPayload, 'AuthorizeRequest');
assert.equal(capturedPayload.version, '1');
assert.equal(capturedPayload.application_id, signer.applicationId);
assert.equal(capturedPayload.name, 'App Teste Schema');
});
it('deve gerar payload válido para SignRequest no sign()', async () => {
const signer = new CyberCoreSigner();
let capturedPayload = null;
signer._fetch = async (endpoint, options) => {
if (endpoint === '/ccsp/v1/sign') {
capturedPayload = JSON.parse(options.body);
return {
version: '1',
request_id: capturedPayload.request_id,
status: 'signed',
signature: 'SIG_BASE64',
certificate: 'CERT_BASE64',
algorithm: capturedPayload.algorithm,
document_name: capturedPayload.document_name
};
}
return {};
};
await signer.sign({
certificateId: 'cert-1',
digest: Buffer.alloc(32, 5).toString('base64'),
documentName: 'documento_auditoria.pdf',
documentType: 'PDF'
});
assert.ok(capturedPayload);
validatePayloadAgainstDefinition(capturedPayload, 'SignRequest');
assert.equal(capturedPayload.version, '1');
assert.equal(capturedPayload.operation, 'sign');
assert.equal(capturedPayload.algorithm, 'SHA256withRSA');
assert.ok(capturedPayload.request_id);
assert.ok(capturedPayload.nonce);
assert.ok(capturedPayload.expires_at);
});
it('deve gerar payload válido para SignPdfRequest no signPdfDocument()', async () => {
const signer = new CyberCoreSigner();
let capturedPayload = null;
signer._fetch = async (endpoint, options) => {
if (endpoint === '/ccsp/v1/sign-pdf') {
capturedPayload = JSON.parse(options.body);
return {
version: '1',
request_id: capturedPayload.request_id,
status: 'signed',
signed_pdf: 'MOCK_SIGNED_PDF_BASE64',
certificate: 'CERT_BASE64',
algorithm: 'SHA256withRSA',
document_name: capturedPayload.document_name
};
}
return {};
};
await signer.signPdfDocument({
pdf: Buffer.from('%PDF-1.4 Mock Raw PDF Content'),
certificateId: 'token-a3-smartcard',
documentName: 'termo_adesao.pdf',
page: 1,
x: 100,
y: 200,
scale: 1.0,
sealThemeId: 'icp-brasil'
});
assert.ok(capturedPayload);
validatePayloadAgainstDefinition(capturedPayload, 'SignPdfRequest');
assert.equal(capturedPayload.version, '1');
assert.equal(capturedPayload.operation, 'sign_pdf');
assert.equal(capturedPayload.page, 1);
assert.equal(capturedPayload.seal_theme_id, 'icp-brasil');
});
it('deve gerar payload válido para SignXmlRequest no signXmlDocument()', async () => {
const signer = new CyberCoreSigner();
let capturedPayload = null;
signer._fetch = async (endpoint, options) => {
if (endpoint === '/ccsp/v1/sign-xml') {
capturedPayload = JSON.parse(options.body);
return {
version: '1',
request_id: capturedPayload.request_id,
status: 'signed',
signed_xml: '<nfeProc>...</nfeProc>',
certificate: 'CERT_BASE64',
algorithm: 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256',
document_name: capturedPayload.document_name
};
}
return {};
};
await signer.signXmlDocument({
xml: '<nfeProc><infNFe Id="NFe3524">Dados</infNFe></nfeProc>',
certificateId: 'cert-a1-pj',
documentName: 'nota_fiscal.xml',
profile: 'XADES_BES',
targetElementTag: 'infNFe',
targetElementId: 'NFe3524'
});
assert.ok(capturedPayload);
validatePayloadAgainstDefinition(capturedPayload, 'SignXmlRequest');
assert.equal(capturedPayload.version, '1');
assert.equal(capturedPayload.operation, 'sign_xml');
assert.equal(capturedPayload.profile, 'XADES_BES');
assert.equal(capturedPayload.target_element_tag, 'infNFe');
});
it('deve gerar payload válido para AuthenticateRequest no authenticate()', async () => {
const signer = new CyberCoreSigner();
let capturedPayload = null;
signer._fetch = async (endpoint, options) => {
if (endpoint === '/ccsp/v1/authenticate') {
capturedPayload = JSON.parse(options.body);
return {
version: '1',
request_id: capturedPayload.request_id,
status: 'authenticated',
signature: 'SIG_BASE64',
certificate: Buffer.from('CN=DOUTOR MARCOS:12345678900').toString('base64'),
algorithm: 'SHA256withRSA',
subject_name: 'DOUTOR MARCOS',
subject_cpf_cnpj: '12345678900'
};
}
return {};
};
await signer.authenticate({
certificateId: 'cert-oab',
challenge: 'random-server-challenge-string'
});
assert.ok(capturedPayload);
validatePayloadAgainstDefinition(capturedPayload, 'AuthenticateRequest');
assert.equal(capturedPayload.version, '1');
assert.equal(capturedPayload.operation, 'authenticate');
assert.equal(capturedPayload.challenge, 'random-server-challenge-string');
});
});
+96 -1
View File
@@ -3,10 +3,22 @@ import assert from 'node:assert/strict';
import { CyberCoreSigner } from '../src/signer-sdk.js';
import {
CyberCoreError,
AgentNotRunningError,
UserCancelledError,
CertificateNotFoundError,
CertificateExpiredError,
CertificateInvalidError,
PrivateKeyNotAvailableError,
PinRequiredError,
PinIncorrectError,
TokenNotFoundError,
RequestExpiredError,
ReplayDetectedError,
InvalidOriginError,
UnsupportedAlgorithmError,
ProviderNotAvailableError,
SignatureFailedError,
TimeoutError,
ApplicationNotAuthorizedError
} from '../src/errors.js';
@@ -25,12 +37,25 @@ describe('@cybercore/signer-sdk - Testes Unitários do SDK', () => {
const signer = new CyberCoreSigner({
baseUrl: 'http://127.0.0.1:21325/',
applicationId: customAppId,
applicationName: 'Portal Minha Empresa'
applicationName: 'Portal Minha Empresa',
timeoutMs: 45000
});
assert.equal(signer.baseUrl, 'http://127.0.0.1:21325');
assert.equal(signer.applicationId, customAppId);
assert.equal(signer.applicationName, 'Portal Minha Empresa');
assert.equal(signer.timeoutMs, 45000);
});
it('deve montar baseUrl automaticamente quando host e port forem especificados', () => {
const signer = new CyberCoreSigner({
host: 'localhost',
port: 29999
});
assert.equal(signer.host, 'localhost');
assert.equal(signer.port, 29999);
assert.equal(signer.baseUrl, 'http://localhost:29999');
});
it('deve retornar as capacidades criptográficas do agente em getCapabilities()', async () => {
@@ -69,6 +94,26 @@ describe('@cybercore/signer-sdk - Testes Unitários do SDK', () => {
CertificateNotFoundError
);
assert.throws(
() => signer._throwMappedError('CERTIFICATE_EXPIRED', 'Certificado expirado'),
CertificateExpiredError
);
assert.throws(
() => signer._throwMappedError('CERTIFICATE_INVALID', 'Certificado inválido'),
CertificateInvalidError
);
assert.throws(
() => signer._throwMappedError('PRIVATE_KEY_NOT_AVAILABLE', 'Sem chave privada'),
PrivateKeyNotAvailableError
);
assert.throws(
() => signer._throwMappedError('PIN_REQUIRED', 'PIN obrigatório'),
PinRequiredError
);
assert.throws(
() => signer._throwMappedError('PIN_INCORRECT', 'PIN errado'),
PinIncorrectError
@@ -79,6 +124,46 @@ describe('@cybercore/signer-sdk - Testes Unitários do SDK', () => {
TokenNotFoundError
);
assert.throws(
() => signer._throwMappedError('TOKEN_REMOVED', 'Token removido'),
TokenNotFoundError
);
assert.throws(
() => signer._throwMappedError('REQUEST_EXPIRED', 'Expirou'),
RequestExpiredError
);
assert.throws(
() => signer._throwMappedError('REPLAY_DETECTED', 'Replay'),
ReplayDetectedError
);
assert.throws(
() => signer._throwMappedError('INVALID_ORIGIN', 'Origem inválida'),
InvalidOriginError
);
assert.throws(
() => signer._throwMappedError('UNSUPPORTED_ALGORITHM', 'Algoritmo inválido'),
UnsupportedAlgorithmError
);
assert.throws(
() => signer._throwMappedError('PROVIDER_NOT_AVAILABLE', 'Provider indisponível'),
ProviderNotAvailableError
);
assert.throws(
() => signer._throwMappedError('SIGNATURE_FAILED', 'Falha na assinatura'),
SignatureFailedError
);
assert.throws(
() => signer._throwMappedError('TIMEOUT_ERROR', 'Timeout'),
TimeoutError
);
assert.throws(
() => signer._throwMappedError('APPLICATION_NOT_AUTHORIZED', 'Não autorizada'),
ApplicationNotAuthorizedError
@@ -90,6 +175,16 @@ describe('@cybercore/signer-sdk - Testes Unitários do SDK', () => {
);
});
it('deve fornecer orientação acionável rica em AgentNotRunningError', () => {
const err = new AgentNotRunningError(undefined, { host: '127.0.0.1', port: 21325 });
assert.equal(err.code, 'AGENT_NOT_RUNNING');
assert.equal(err.host, '127.0.0.1');
assert.equal(err.port, 21325);
assert.ok(err.actionableGuidance.includes('bandeja do sistema'));
assert.ok(err.actionableGuidance.includes('instalador oficial'));
assert.ok(err.actionableGuidance.includes('21325'));
});
it('deve validar parâmetros obrigatórios no método sign()', async () => {
const signer = new CyberCoreSigner();