feat: initial commit - cybercore signer desktop agent

This commit is contained in:
Claudecio Martins
2026-10-11 08:20:24 -03:00
commit ccfa358f97
206 changed files with 23952 additions and 0 deletions
@@ -0,0 +1,43 @@
package br.com.cybercore.signer.core.config;
import br.com.cybercore.signer.core.pdf.seal.CpfDisplayMode;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
/**
* Modelo de configurações gerais e de segurança do CyberCore Signer.
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record AgentConfiguration(
boolean requireConfirmation,
boolean alwaysPromptPin,
boolean autoStart,
String defaultSealThemeId,
CpfDisplayMode defaultCpfDisplayMode,
boolean defaultIncludeDateTime,
int serverPort
) {
public AgentConfiguration {
if (defaultSealThemeId == null || defaultSealThemeId.isBlank()) {
defaultSealThemeId = "icp-brasil";
}
if (defaultCpfDisplayMode == null) {
defaultCpfDisplayMode = CpfDisplayMode.FULL;
}
if (serverPort <= 0) {
serverPort = 18420;
}
}
public static AgentConfiguration defaultConfiguration() {
return new AgentConfiguration(
true,
false,
false,
"icp-brasil",
CpfDisplayMode.FULL,
true,
18420
);
}
}
@@ -0,0 +1,94 @@
package br.com.cybercore.signer.core.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Objects;
/**
* Serviço de gerenciamento e persistência das configurações do CyberCore Signer.
* Salva e recupera as preferências do usuário no arquivo ~/.cybercore/config.json.
*/
public final class AgentConfigurationService {
private static final Logger logger = LoggerFactory.getLogger(AgentConfigurationService.class);
private static final String CONFIG_FILE_NAME = "config.json";
private static final AgentConfigurationService INSTANCE = new AgentConfigurationService();
private final Path configPath;
private final ObjectMapper objectMapper;
private AgentConfiguration currentConfig;
public static AgentConfigurationService getInstance() {
return INSTANCE;
}
public AgentConfigurationService() {
this(resolveDefaultConfigPath());
}
public AgentConfigurationService(Path configPath) {
this.configPath = Objects.requireNonNull(configPath);
this.objectMapper = new ObjectMapper()
.enable(SerializationFeature.INDENT_OUTPUT);
this.currentConfig = load();
}
private static Path resolveDefaultConfigPath() {
String userHome = System.getProperty("user.home", ".");
return Path.of(userHome, ".cybercore", CONFIG_FILE_NAME);
}
public synchronized AgentConfiguration getConfig() {
return currentConfig;
}
public synchronized AgentConfiguration loadConfiguration() {
return getConfig();
}
public synchronized void updateConfig(AgentConfiguration newConfig) {
Objects.requireNonNull(newConfig, "Configuração não pode ser nula.");
this.currentConfig = newConfig;
save();
}
public synchronized void saveConfiguration(AgentConfiguration newConfig) {
updateConfig(newConfig);
}
private AgentConfiguration load() {
try {
if (Files.exists(configPath)) {
AgentConfiguration cfg = objectMapper.readValue(configPath.toFile(), AgentConfiguration.class);
logger.info("Configurações do agente carregadas de: {}", configPath);
return cfg;
}
} catch (Exception e) {
logger.warn("Falha ao ler {}. Usando configurações padrão: {}", configPath, e.getMessage());
}
return AgentConfiguration.defaultConfiguration();
}
private void save() {
try {
if (configPath.getParent() != null && !Files.exists(configPath.getParent())) {
Files.createDirectories(configPath.getParent());
}
objectMapper.writeValue(configPath.toFile(), currentConfig);
logger.info("Configurações do agente salvas com sucesso em: {}", configPath);
} catch (IOException e) {
logger.error("Erro ao salvar configurações em {}: {}", configPath, e.getMessage(), e);
}
}
public Path getConfigPath() {
return configPath;
}
}
@@ -0,0 +1,66 @@
package br.com.cybercore.signer.core.crypto;
import br.com.cybercore.signer.core.protocol.CcspErrorCode;
import br.com.cybercore.signer.core.provider.CertificateProviderException;
/**
* Algoritmos criptográficos de assinatura suportados pelo CyberCore Signer.
*/
public enum DigestAlgorithm {
SHA256_WITH_RSA("SHA256withRSA", "SHA-256", 32),
SHA384_WITH_RSA("SHA384withRSA", "SHA-384", 48),
SHA512_WITH_RSA("SHA512withRSA", "SHA-512", 64),
SHA256_WITH_ECDSA("SHA256withECDSA", "SHA-256", 32),
SHA384_WITH_ECDSA("SHA384withECDSA", "SHA-384", 48),
SHA512_WITH_ECDSA("SHA512withECDSA", "SHA-512", 64);
private final String jcaName;
private final String digestName;
private final int expectedDigestLengthBytes;
DigestAlgorithm(String jcaName, String digestName, int expectedDigestLengthBytes) {
this.jcaName = jcaName;
this.digestName = digestName;
this.expectedDigestLengthBytes = expectedDigestLengthBytes;
}
public String getJcaName() {
return jcaName;
}
public String getDigestName() {
return digestName;
}
public int getExpectedDigestLengthBytes() {
return expectedDigestLengthBytes;
}
public static DigestAlgorithm fromName(String name) throws CertificateProviderException {
if (name == null || name.isBlank()) {
throw new CertificateProviderException(CcspErrorCode.UNSUPPORTED_ALGORITHM, "Algoritmo não especificado.");
}
for (DigestAlgorithm alg : values()) {
if (alg.jcaName.equalsIgnoreCase(name.trim())) {
return alg;
}
}
throw new CertificateProviderException(
CcspErrorCode.UNSUPPORTED_ALGORITHM,
"Algoritmo não suportado pelo CyberCore Signer: " + name
);
}
public void validateDigestLength(byte[] digest) throws CertificateProviderException {
if (digest == null) {
throw new CertificateProviderException(CcspErrorCode.SIGNATURE_FAILED, "Digest não pode ser nulo.");
}
if (digest.length != expectedDigestLengthBytes) {
throw new CertificateProviderException(
CcspErrorCode.SIGNATURE_FAILED,
String.format("Tamanho inválido para digest %s: esperado %d bytes, recebido %d bytes.",
digestName, expectedDigestLengthBytes, digest.length)
);
}
}
}
@@ -0,0 +1,169 @@
package br.com.cybercore.signer.core.crypto;
import br.com.cybercore.signer.core.domain.CertificateStatus;
import br.com.cybercore.signer.core.domain.ManagedCertificate;
import br.com.cybercore.signer.core.domain.SigningContext;
import br.com.cybercore.signer.core.domain.SigningResult;
import br.com.cybercore.signer.core.protocol.CcspErrorCode;
import br.com.cybercore.signer.core.provider.CertificateManager;
import br.com.cybercore.signer.core.provider.CertificateProvider;
import br.com.cybercore.signer.core.provider.CertificateProviderException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
/**
* Serviço central de assinatura digital do CyberCore Signer.
* Orquestra validações rigorosas de protocolo e algoritmos, resolução de certificados
* e delegação aos provedores criptográficos JCA/JCE (Windows-MY, A1, PKCS#11).
* Nunca implementa algoritmos criptográficos manuais e nunca expõe a chave privada.
*/
public class SigningService {
private static final Logger logger = LoggerFactory.getLogger(SigningService.class);
private final CertificateManager certificateManager;
public SigningService(CertificateManager certificateManager) {
this.certificateManager = Objects.requireNonNull(certificateManager, "certificateManager must not be null");
}
/**
* Executa a assinatura digital local de um digest fornecido por uma aplicação autorizada.
*
* @param context Contexto da requisição contendo digest, algoritmo, ID da requisição e certificado indicado.
* @return Resultado imutável da assinatura com os bytes assinados e certificados em Base64.
* @throws CertificateProviderException Se qualquer validação, dispositivo ou operação criptográfica falhar.
*/
public SigningResult sign(SigningContext context) throws CertificateProviderException {
Objects.requireNonNull(context, "Contexto de assinatura não pode ser nulo.");
if (context.requestId() == null || context.requestId().isBlank()) {
throw new CertificateProviderException(
CcspErrorCode.SIGNATURE_FAILED,
"O campo 'request_id' é obrigatório para correlação de assinatura."
);
}
// 1. Validação de algoritmo suportado e tamanho do digest
DigestAlgorithm algorithm = DigestAlgorithm.fromName(context.algorithm());
algorithm.validateDigestLength(context.digest());
// 2. Resolução do certificado alvo
ManagedCertificate targetCertificate = resolveCertificate(context.certificateHint());
// 3. Validação do ciclo de vida e disponibilidade de chave privada
validateCertificateState(targetCertificate);
// 4. Obtenção do provedor responsável
CertificateProvider provider = certificateManager.getProviderForCertificate(targetCertificate.id());
if (provider == null || !provider.isAvailable()) {
throw new CertificateProviderException(
CcspErrorCode.PROVIDER_NOT_AVAILABLE,
"O provedor para o certificado '" + targetCertificate.alias() + "' não está disponível no sistema."
);
}
logger.info("Assinando digest para requestId='{}', appId='{}', cert='{}', provider='{}', alg='{}'",
context.requestId(), context.applicationId(), targetCertificate.alias(),
provider.getProviderId(), algorithm.getJcaName());
// Se o contexto não tiver hint explícito, utiliza o ID do certificado resolvido
SigningContext effectiveContext = (context.certificateHint() == null || context.certificateHint().isBlank())
? new SigningContext(context.requestId(), context.applicationId(), context.digest(),
context.algorithm(), targetCertificate.id(), context.requestedAt())
: context;
// 5. Execução no provedor JCA/JCE
SigningResult result = provider.sign(effectiveContext);
if (result == null || result.signatureBytes() == null || result.signatureBytes().length == 0) {
throw new CertificateProviderException(
CcspErrorCode.SIGNATURE_FAILED,
"O provedor retornou uma assinatura nula ou vazia."
);
}
logger.info("Assinatura digital concluída com sucesso para requestId='{}'. Tamanho assinatura: {} bytes",
context.requestId(), result.signatureBytes().length);
return result;
}
private ManagedCertificate resolveCertificate(String hint) throws CertificateProviderException {
if (hint != null && !hint.isBlank()) {
Optional<ManagedCertificate> found = certificateManager.findByHint(hint);
if (found.isEmpty()) {
// Tenta rediscovery caso um token tenha acabado de ser conectado
certificateManager.discoverCertificates();
found = certificateManager.findByHint(hint);
}
if (found.isPresent()) {
return found.get();
}
throw new CertificateProviderException(
CcspErrorCode.CERTIFICATE_NOT_FOUND,
"Nenhum certificado encontrado para o identificador ou hint: " + hint
);
}
// Sem hint: seleciona automaticamente se houver apenas um certificado operacional disponível
List<ManagedCertificate> available = certificateManager.getAvailableCertificates();
if (available.isEmpty()) {
// Tenta redescobrir
certificateManager.discoverCertificates();
available = certificateManager.getAvailableCertificates();
}
if (available.isEmpty()) {
throw new CertificateProviderException(
CcspErrorCode.CERTIFICATE_NOT_FOUND,
"Nenhum certificado com chave privada disponível foi encontrado na máquina."
);
}
if (available.size() == 1) {
return available.get(0);
}
throw new CertificateProviderException(
CcspErrorCode.CERTIFICATE_NOT_FOUND,
"Existem múltiplos certificados disponíveis (" + available.size() +
"). Especifique o 'certificate_hint' para selecionar o certificado desejado."
);
}
private void validateCertificateState(ManagedCertificate cert) throws CertificateProviderException {
if (cert.status() == CertificateStatus.EXPIRED) {
throw new CertificateProviderException(
CcspErrorCode.CERTIFICATE_EXPIRED,
"O certificado '" + cert.alias() + "' está expirado desde " + cert.notAfter()
);
}
if (cert.status() == CertificateStatus.NOT_YET_VALID) {
throw new CertificateProviderException(
CcspErrorCode.CERTIFICATE_INVALID,
"O certificado '" + cert.alias() + "' ainda não é válido. Início da validade: " + cert.notBefore()
);
}
if (cert.status() == CertificateStatus.NO_PRIVATE_KEY) {
throw new CertificateProviderException(
CcspErrorCode.PRIVATE_KEY_NOT_AVAILABLE,
"O certificado '" + cert.alias() + "' não possui chave privada acessível para assinatura."
);
}
if (cert.status() == CertificateStatus.REVOKED) {
throw new CertificateProviderException(
CcspErrorCode.CERTIFICATE_INVALID,
"O certificado '" + cert.alias() + "' consta como revogado."
);
}
}
public CertificateManager getCertificateManager() {
return certificateManager;
}
}
@@ -0,0 +1,230 @@
package br.com.cybercore.signer.core.crypto;
import br.com.cybercore.signer.core.domain.CertificateStatus;
import br.com.cybercore.signer.core.domain.CertificateType;
import br.com.cybercore.signer.core.domain.ManagedCertificate;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.cert.CertificateEncodingException;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.time.Instant;
import java.util.Collection;
import java.util.HexFormat;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Utilitários para parsing, inspeção e extração de metadados de certificados X.509
* com suporte nativo a extensões da Infraestrutura de Chaves Públicas Brasileira (ICP-Brasil).
*/
public final class X509CertificateUtils {
private static final Logger logger = LoggerFactory.getLogger(X509CertificateUtils.class);
private static final Pattern CN_PATTERN = Pattern.compile("CN=([^,]+)", Pattern.CASE_INSENSITIVE);
private static final Pattern CPF_PATTERN = Pattern.compile("(\\d{11})");
private static final Pattern CNPJ_PATTERN = Pattern.compile("(\\d{14})");
private X509CertificateUtils() {
// Utilitário estático
}
/**
* Decodifica um certificado X.509 a partir de bytes codificados (DER ou PEM).
*/
public static X509Certificate parseCertificate(byte[] encoded) throws CertificateException {
CertificateFactory factory = CertificateFactory.getInstance("X.509");
return (X509Certificate) factory.generateCertificate(new ByteArrayInputStream(encoded));
}
/**
* Converte um X509Certificate em um ManagedCertificate imutável de domínio.
*/
public static ManagedCertificate toManagedCertificate(
String id,
String alias,
X509Certificate cert,
CertificateType type,
boolean hasPrivateKey,
String providerId,
String hardwareDeviceName
) {
String subject = cert.getSubjectX500Principal().getName();
String issuer = cert.getIssuerX500Principal().getName();
String serial = cert.getSerialNumber().toString(16).toUpperCase();
Instant notBefore = cert.getNotBefore().toInstant();
Instant notAfter = cert.getNotAfter().toInstant();
Instant now = Instant.now();
CertificateStatus status;
if (!hasPrivateKey) {
status = CertificateStatus.NO_PRIVATE_KEY;
} else if (now.isBefore(notBefore)) {
status = CertificateStatus.NOT_YET_VALID;
} else if (now.isAfter(notAfter)) {
status = CertificateStatus.EXPIRED;
} else {
status = CertificateStatus.AVAILABLE;
}
String cn = extractCommonName(subject);
String sha1 = calculateFingerprint(cert, "SHA-1");
String sha256 = calculateFingerprint(cert, "SHA-256");
String cpf = extractCpf(cert, cn);
String cnpj = extractCnpj(cert, cn);
int keySize = extractKeySize(cert);
String alg = cert.getPublicKey().getAlgorithm();
return new ManagedCertificate(
id,
alias,
type,
status,
subject,
issuer,
serial,
sha1,
sha256,
notBefore,
notAfter,
alg,
keySize,
cn,
cpf,
cnpj,
providerId,
hardwareDeviceName
);
}
public static String extractCommonName(String dn) {
if (dn == null) return "Desconhecido";
Matcher matcher = CN_PATTERN.matcher(dn);
if (matcher.find()) {
return matcher.group(1).trim();
}
return dn;
}
public static String calculateFingerprint(X509Certificate cert, String algorithm) {
try {
MessageDigest md = MessageDigest.getInstance(algorithm);
byte[] digest = md.digest(cert.getEncoded());
return HexFormat.ofDelimiter(":").formatHex(digest).toUpperCase();
} catch (NoSuchAlgorithmException | CertificateEncodingException e) {
logger.debug("Não foi possível calcular fingerprint {} do certificado: {}", algorithm, e.getMessage());
return "";
}
}
private static int extractKeySize(X509Certificate cert) {
try {
String alg = cert.getPublicKey().getAlgorithm();
if ("RSA".equalsIgnoreCase(alg)) {
java.security.interfaces.RSAPublicKey rsa = (java.security.interfaces.RSAPublicKey) cert.getPublicKey();
return rsa.getModulus().bitLength();
} else if ("EC".equalsIgnoreCase(alg) || "ECDSA".equalsIgnoreCase(alg)) {
java.security.interfaces.ECPublicKey ec = (java.security.interfaces.ECPublicKey) cert.getPublicKey();
return ec.getParams().getCurve().getField().getFieldSize();
}
} catch (Exception ignored) {
// Se falhar em extrair tamanho específico da chave, retorna 0
}
return 0;
}
/**
* Tenta extrair CPF do certificado (via ICP-Brasil SAN ou padrão comum no CN: "NOME:12345678901").
*/
public static String extractCpf(X509Certificate cert, String commonName) {
// Tenta padrão CN ICP-Brasil comum ("FULANO DE TAL:00011122233")
if (commonName != null && commonName.contains(":")) {
String[] parts = commonName.split(":");
if (parts.length > 1) {
String candidate = parts[parts.length - 1].trim();
if (CPF_PATTERN.matcher(candidate).matches()) {
return candidate;
}
}
}
// Tenta SubjectAlternativeNames
try {
Collection<List<?>> sans = cert.getSubjectAlternativeNames();
if (sans != null) {
for (List<?> item : sans) {
if (item.size() >= 2 && item.get(1) instanceof byte[] bytes) {
String content = new String(bytes, StandardCharsets.ISO_8859_1);
Matcher m = CPF_PATTERN.matcher(content);
if (m.find()) {
return m.group(1);
}
}
}
}
} catch (Exception ignored) {
// SAN não disponível ou inacessível
}
return null;
}
/**
* Tenta extrair CNPJ do certificado para pessoas jurídicas.
*/
public static String extractCnpj(X509Certificate cert, String commonName) {
if (commonName != null && commonName.contains(":")) {
String[] parts = commonName.split(":");
if (parts.length > 1) {
String candidate = parts[parts.length - 1].trim();
if (CNPJ_PATTERN.matcher(candidate).matches()) {
return candidate;
}
}
}
try {
Collection<List<?>> sans = cert.getSubjectAlternativeNames();
if (sans != null) {
for (List<?> item : sans) {
if (item.size() >= 2 && item.get(1) instanceof byte[] bytes) {
String content = new String(bytes, StandardCharsets.ISO_8859_1);
Matcher m = CNPJ_PATTERN.matcher(content);
if (m.find()) {
return m.group(1);
}
}
}
}
} catch (Exception ignored) {
// SAN não disponível ou inacessível
}
return null;
}
/**
* Extrai o nome amigável do titular (removendo sufixo :CPF ou :CNPJ comum em ICP-Brasil).
*/
public static String extractHolderName(String commonName) {
if (commonName == null || commonName.isBlank()) {
return "Titular do Certificado";
}
if (commonName.contains(":")) {
String[] parts = commonName.split(":");
if (parts.length > 0 && !parts[0].isBlank()) {
return parts[0].trim();
}
}
return commonName.trim();
}
}
@@ -0,0 +1,51 @@
package br.com.cybercore.signer.core.diagnostics;
import java.io.Serializable;
import java.time.Instant;
import java.util.Collections;
import java.util.List;
import java.util.Map;
/**
* Relatório consolidado de integridade e diagnósticos do CyberCore Signer.
*/
public record DiagnosticReport(
Instant generatedAt,
String osName,
String osVersion,
String osArch,
String javaVersion,
String javaVendor,
String javaRuntime,
boolean isWindows,
boolean cryptoEngineOk,
List<String> registeredSecurityProviders,
boolean windowsStoreSupported,
boolean pkcs11ModuleAvailable,
int totalCertificatesFound,
int a1CertificatesCount,
int a3CertificatesCount,
boolean localServerRunning,
int localServerPort,
Map<String, String> additionalDetails
) implements Serializable {
public DiagnosticReport {
registeredSecurityProviders = registeredSecurityProviders != null
? List.copyOf(registeredSecurityProviders)
: Collections.emptyList();
additionalDetails = additionalDetails != null
? Map.copyOf(additionalDetails)
: Collections.emptyMap();
}
@Override
public List<String> registeredSecurityProviders() {
return Collections.unmodifiableList(registeredSecurityProviders);
}
@Override
public Map<String, String> additionalDetails() {
return Collections.unmodifiableMap(additionalDetails);
}
}
@@ -0,0 +1,87 @@
package br.com.cybercore.signer.core.diagnostics;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.security.Provider;
import java.security.Security;
import java.security.Signature;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Serviço responsável por verificar as capacidades do sistema, JVM e engines criptográficas.
*/
public class DiagnosticService {
private static final Logger logger = LoggerFactory.getLogger(DiagnosticService.class);
public DiagnosticReport runDiagnostic() {
logger.info("Iniciando diagnóstico do ambiente e engine criptográfica...");
String osName = System.getProperty("os.name", "Desconhecido");
String osVersion = System.getProperty("os.version", "Desconhecido");
String osArch = System.getProperty("os.arch", "Desconhecido");
boolean isWindows = osName.toLowerCase().contains("win");
String javaVersion = System.getProperty("java.version", "Desconhecido");
String javaVendor = System.getProperty("java.vendor", "Desconhecido");
String javaRuntime = System.getProperty("java.runtime.name", "Desconhecido");
List<String> providersList = new ArrayList<>();
boolean windowsStoreSupported = false;
boolean pkcs11ModuleAvailable = false;
for (Provider p : Security.getProviders()) {
providersList.add(p.getName() + " v" + p.getVersionStr());
if ("SunMSCAPI".equalsIgnoreCase(p.getName())) {
windowsStoreSupported = true;
}
if ("SunPKCS11".equalsIgnoreCase(p.getName())) {
pkcs11ModuleAvailable = true;
}
}
boolean cryptoEngineOk = verifyCryptoAlgorithms();
Map<String, String> details = new HashMap<>();
details.put("VirtualThreadsSupported", "true");
details.put("DefaultEncoding", System.getProperty("file.encoding", "UTF-8"));
return new DiagnosticReport(
Instant.now(),
osName,
osVersion,
osArch,
javaVersion,
javaVendor,
javaRuntime,
isWindows,
cryptoEngineOk,
providersList,
windowsStoreSupported,
pkcs11ModuleAvailable,
0,
0,
0,
false,
39741,
details
);
}
private boolean verifyCryptoAlgorithms() {
try {
Signature.getInstance("SHA256withRSA");
Signature.getInstance("SHA384withRSA");
Signature.getInstance("SHA512withRSA");
return true;
} catch (Exception e) {
logger.error("Falha ao inicializar algoritmos padrão JCA/JCE: {}", e.getMessage());
return false;
}
}
}
@@ -0,0 +1,30 @@
package br.com.cybercore.signer.core.domain;
/**
* Estado de validade e disponibilidade de um certificado.
*/
public enum CertificateStatus {
AVAILABLE("Disponível", true),
EXPIRED("Expirado", false),
NOT_YET_VALID("Ainda não válido", false),
NO_PRIVATE_KEY("Sem chave privada", false),
TOKEN_DISCONNECTED("Token desconectado", false),
REVOKED("Revogado", false),
UNKNOWN("Desconhecido", false);
private final String description;
private final boolean usableForSigning;
CertificateStatus(String description, boolean usableForSigning) {
this.description = description;
this.usableForSigning = usableForSigning;
}
public String getDescription() {
return description;
}
public boolean isUsableForSigning() {
return usableForSigning;
}
}
@@ -0,0 +1,32 @@
package br.com.cybercore.signer.core.domain;
/**
* Tipo de certificado digital suportado pelo CyberCore Signer.
*/
public enum CertificateType {
/**
* Certificado em software (.pfx / .p12).
*/
A1("A1", "Certificado Local"),
/**
* Certificado em hardware (Smart Card / Token USB / PKCS#11 / Windows Store).
*/
A3("A3", "Token / Smart Card");
private final String code;
private final String label;
CertificateType(String code, String label) {
this.code = code;
this.label = label;
}
public String getCode() {
return code;
}
public String getLabel() {
return label;
}
}
@@ -0,0 +1,56 @@
package br.com.cybercore.signer.core.domain;
import java.io.Serializable;
import java.time.Instant;
import java.util.Objects;
/**
* Representação imutável de um certificado digital gerenciado pelo CyberCore Signer.
* Contém apenas metadados públicos e nunca expõe referências diretas de chave privada.
*/
public record ManagedCertificate(
String id,
String alias,
CertificateType type,
CertificateStatus status,
String subjectDn,
String issuerDn,
String serialNumber,
String sha1Fingerprint,
String sha256Fingerprint,
Instant notBefore,
Instant notAfter,
String algorithm,
int keySize,
String commonName,
String cpf,
String cnpj,
String providerId,
String hardwareDeviceName
) implements Serializable {
public ManagedCertificate {
Objects.requireNonNull(id, "id must not be null");
Objects.requireNonNull(alias, "alias must not be null");
Objects.requireNonNull(type, "type must not be null");
Objects.requireNonNull(status, "status must not be null");
Objects.requireNonNull(subjectDn, "subjectDn must not be null");
Objects.requireNonNull(issuerDn, "issuerDn must not be null");
}
/**
* Verifica se o certificado está atualmente dentro do período de validade temporal.
*/
public boolean isCurrentlyValid() {
Instant now = Instant.now();
return (notBefore == null || !now.isBefore(notBefore))
&& (notAfter == null || !now.isAfter(notAfter));
}
/**
* Verifica se o certificado está apto a assinar.
*/
public boolean canSign() {
return status.isUsableForSigning() && isCurrentlyValid();
}
}
@@ -0,0 +1,55 @@
package br.com.cybercore.signer.core.domain;
import java.time.Instant;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
/**
* Contexto da operação de assinatura a ser processada pelo SigningService / CertificateProvider.
*/
public record SigningContext(
String requestId,
String applicationId,
byte[] digest,
String algorithm,
String certificateHint,
Instant requestedAt
) {
public SigningContext {
Objects.requireNonNull(requestId, "requestId must not be null");
Objects.requireNonNull(applicationId, "applicationId must not be null");
Objects.requireNonNull(digest, "digest must not be null");
Objects.requireNonNull(algorithm, "algorithm must not be null");
digest = digest.clone();
if (requestedAt == null) {
requestedAt = Instant.now();
}
}
@Override
public byte[] digest() {
return digest.clone();
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
SigningContext that = (SigningContext) o;
return Objects.equals(requestId, that.requestId)
&& Objects.equals(applicationId, that.applicationId)
&& Arrays.equals(digest, that.digest)
&& Objects.equals(algorithm, that.algorithm)
&& Objects.equals(certificateHint, that.certificateHint)
&& Objects.equals(requestedAt, that.requestedAt);
}
@Override
public int hashCode() {
int result = Objects.hash(requestId, applicationId, algorithm, certificateHint, requestedAt);
result = 31 * result + Arrays.hashCode(digest);
return result;
}
}
@@ -0,0 +1,38 @@
package br.com.cybercore.signer.core.domain;
import java.security.PrivateKey;
import java.security.Provider;
import java.security.cert.X509Certificate;
import java.util.Arrays;
import java.util.Objects;
/**
* Contêiner seguro para material criptográfico de assinatura (chave privada e cadeia X.509).
* Permite que serviços de alto nível (PDF/PAdES e XMLDSig) realizem assinaturas estruturadas.
* A chave privada e a cadeia permanecem estritamente na máquina local do usuário.
*/
public record SigningKeyMaterial(
PrivateKey privateKey,
X509Certificate[] certificateChain,
Provider jcaProvider
) {
public SigningKeyMaterial {
Objects.requireNonNull(privateKey, "Chave privada não pode ser nula.");
Objects.requireNonNull(certificateChain, "Cadeia de certificados não pode ser nula.");
if (certificateChain.length == 0) {
throw new IllegalArgumentException("A cadeia de certificados deve conter ao menos um certificado.");
}
// Cópia defensiva
certificateChain = Arrays.copyOf(certificateChain, certificateChain.length);
}
public X509Certificate signingCertificate() {
return certificateChain[0];
}
@Override
public X509Certificate[] certificateChain() {
return Arrays.copyOf(certificateChain, certificateChain.length);
}
}
@@ -0,0 +1,63 @@
package br.com.cybercore.signer.core.domain;
import java.time.Instant;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
/**
* Resultado da operação de assinatura digital.
*/
public record SigningResult(
String requestId,
byte[] signatureBytes,
String certificateBase64,
List<String> certificateChainBase64,
String algorithm,
Instant signedAt
) {
public SigningResult {
Objects.requireNonNull(requestId, "requestId must not be null");
Objects.requireNonNull(signatureBytes, "signatureBytes must not be null");
Objects.requireNonNull(certificateBase64, "certificateBase64 must not be null");
Objects.requireNonNull(algorithm, "algorithm must not be null");
signatureBytes = signatureBytes.clone();
certificateChainBase64 = certificateChainBase64 != null
? List.copyOf(certificateChainBase64)
: Collections.singletonList(certificateBase64);
if (signedAt == null) {
signedAt = Instant.now();
}
}
@Override
public byte[] signatureBytes() {
return signatureBytes.clone();
}
@Override
public List<String> certificateChainBase64() {
return Collections.unmodifiableList(certificateChainBase64);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
SigningResult that = (SigningResult) o;
return Objects.equals(requestId, that.requestId)
&& Arrays.equals(signatureBytes, that.signatureBytes)
&& Objects.equals(certificateBase64, that.certificateBase64)
&& Objects.equals(certificateChainBase64, that.certificateChainBase64)
&& Objects.equals(algorithm, that.algorithm)
&& Objects.equals(signedAt, that.signedAt);
}
@Override
public int hashCode() {
int result = Objects.hash(requestId, certificateBase64, certificateChainBase64, algorithm, signedAt);
result = 31 * result + Arrays.hashCode(signatureBytes);
return result;
}
}
@@ -0,0 +1,20 @@
package br.com.cybercore.signer.core.pdf;
/**
* Seleção da página do PDF na qual o selo visual da assinatura digital será carimbado.
*/
public enum PdfSealPageChoice {
LAST_PAGE("Última Página (Recomendado)"),
FIRST_PAGE("Primeira Página"),
ALL_PAGES("Todas as Páginas");
private final String description;
PdfSealPageChoice(String description) {
this.description = description;
}
public String getDescription() {
return description;
}
}
@@ -0,0 +1,22 @@
package br.com.cybercore.signer.core.pdf;
/**
* Posição do selo visual de assinatura na página do documento PDF.
*/
public enum PdfSealPosition {
BOTTOM_RIGHT("Canto Inferior Direito"),
BOTTOM_LEFT("Canto Inferior Esquerdo"),
TOP_RIGHT("Canto Superior Direito"),
TOP_LEFT("Canto Superior Esquerdo"),
CUSTOM("Posição Personalizada por Clique na Página");
private final String description;
PdfSealPosition(String description) {
this.description = description;
}
public String getDescription() {
return description;
}
}
@@ -0,0 +1,186 @@
package br.com.cybercore.signer.core.pdf;
import br.com.cybercore.signer.core.pdf.seal.CpfDisplayMode;
import br.com.cybercore.signer.core.pdf.seal.PdfSealTheme;
import br.com.cybercore.signer.core.pdf.seal.PdfSealThemeRegistry;
import java.util.Objects;
/**
* Parâmetros de configuração para a operação de assinatura digital PAdES com selo visual em PDF,
* incluindo suporte a posicionamento predefinido ou coordenadas personalizadas na página,
* e preferências granulares para exibição de CPF e data/hora.
*/
public record PdfSignatureOptions(
String certificateId,
char[] pinOrPassword,
boolean visibleSeal,
PdfSealTheme sealTheme,
PdfSealPosition position,
PdfSealPageChoice pageChoice,
String reason,
String location,
Float customX,
Float customY,
Integer targetPageNumber,
CpfDisplayMode cpfDisplayMode,
Boolean includeDateTime,
String customExtraInfo
) {
public PdfSignatureOptions {
Objects.requireNonNull(certificateId, "ID do certificado não pode ser nulo.");
if (sealTheme == null) {
sealTheme = PdfSealThemeRegistry.getDefaultTheme();
}
if (position == null) {
position = (customX != null && customY != null) ? PdfSealPosition.CUSTOM : PdfSealPosition.BOTTOM_RIGHT;
}
if (pageChoice == null) {
pageChoice = PdfSealPageChoice.LAST_PAGE;
}
if (reason == null || reason.isBlank()) {
reason = "Assinatura Digital de Documento";
}
if (location == null || location.isBlank()) {
location = "Brasil";
}
if (cpfDisplayMode == null) {
cpfDisplayMode = (sealTheme.defaultCpfMode() != null) ? sealTheme.defaultCpfMode() : CpfDisplayMode.FULL;
}
if (includeDateTime == null) {
includeDateTime = sealTheme.defaultIncludeDateTime();
}
}
/**
* Construtor para compatibilidade com assinaturas anteriores a customExtraInfo.
*/
public PdfSignatureOptions(String certificateId, char[] pinOrPassword, boolean visibleSeal,
PdfSealTheme sealTheme, PdfSealPosition position, PdfSealPageChoice pageChoice,
String reason, String location, Float customX, Float customY,
Integer targetPageNumber, CpfDisplayMode cpfDisplayMode, Boolean includeDateTime) {
this(certificateId, pinOrPassword, visibleSeal, sealTheme, position, pageChoice, reason, location,
customX, customY, targetPageNumber, cpfDisplayMode, includeDateTime, null);
}
/**
* Construtor de compatibilidade para assinatura com posições pré-fixadas por enum.
*/
public PdfSignatureOptions(String certificateId, char[] pinOrPassword, boolean visibleSeal,
PdfSealTheme sealTheme, PdfSealPosition position, PdfSealPageChoice pageChoice,
String reason, String location) {
this(certificateId, pinOrPassword, visibleSeal, sealTheme, position, pageChoice, reason, location, null, null, null, null, null, null);
}
public PdfSignatureOptions(String certificateId, char[] pinOrPassword, boolean visibleSeal,
PdfSealTheme sealTheme, PdfSealPosition position, PdfSealPageChoice pageChoice,
String reason, String location, Float customX, Float customY, Integer targetPageNumber) {
this(certificateId, pinOrPassword, visibleSeal, sealTheme, position, pageChoice, reason, location, customX, customY, targetPageNumber, null, null, null);
}
public static Builder builder(String certificateId) {
return new Builder(certificateId);
}
public static final class Builder {
private final String certificateId;
private char[] pinOrPassword;
private boolean visibleSeal = true;
private PdfSealTheme sealTheme;
private PdfSealPosition position = PdfSealPosition.BOTTOM_RIGHT;
private PdfSealPageChoice pageChoice = PdfSealPageChoice.LAST_PAGE;
private String reason = "Assinatura Digital ICP-Brasil";
private String location = "Brasil";
private Float customX;
private Float customY;
private Integer targetPageNumber;
private CpfDisplayMode cpfDisplayMode;
private Boolean includeDateTime;
public Builder(String certificateId) {
this.certificateId = Objects.requireNonNull(certificateId);
}
public Builder pinOrPassword(char[] pin) {
this.pinOrPassword = pin;
return this;
}
public Builder visibleSeal(boolean visible) {
this.visibleSeal = visible;
return this;
}
public Builder sealTheme(PdfSealTheme theme) {
this.sealTheme = theme;
return this;
}
public Builder position(PdfSealPosition pos) {
this.position = pos;
return this;
}
public Builder pageChoice(PdfSealPageChoice choice) {
this.pageChoice = choice;
return this;
}
public Builder reason(String reason) {
this.reason = reason;
return this;
}
public Builder location(String location) {
this.location = location;
return this;
}
public Builder cpfDisplayMode(CpfDisplayMode mode) {
this.cpfDisplayMode = mode;
return this;
}
private String customExtraInfo;
public Builder includeDateTime(boolean include) {
this.includeDateTime = include;
return this;
}
public Builder customExtraInfo(String info) {
this.customExtraInfo = info;
return this;
}
public Builder customPosition(Float x, Float y, Integer pageNumber) {
this.customX = x;
this.customY = y;
this.targetPageNumber = pageNumber;
if (x != null && y != null) {
this.position = PdfSealPosition.CUSTOM;
}
return this;
}
public PdfSignatureOptions build() {
return new PdfSignatureOptions(
certificateId,
pinOrPassword,
visibleSeal,
sealTheme,
position,
pageChoice,
reason,
location,
customX,
customY,
targetPageNumber,
cpfDisplayMode,
includeDateTime,
customExtraInfo
);
}
}
}
@@ -0,0 +1,351 @@
package br.com.cybercore.signer.core.pdf;
import br.com.cybercore.signer.core.domain.ManagedCertificate;
import br.com.cybercore.signer.core.domain.SigningKeyMaterial;
import br.com.cybercore.signer.core.pdf.seal.PdfSealData;
import br.com.cybercore.signer.core.pdf.seal.PdfSealRenderer;
import br.com.cybercore.signer.core.pdf.seal.PdfSealTheme;
import br.com.cybercore.signer.core.protocol.CcspErrorCode;
import br.com.cybercore.signer.core.provider.CertificateManager;
import br.com.cybercore.signer.core.provider.CertificateProviderException;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.PDSignature;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.SignatureInterface;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.bouncycastle.cert.jcajce.JcaCertStore;
import org.bouncycastle.cms.CMSProcessableByteArray;
import org.bouncycastle.cms.CMSSignedData;
import org.bouncycastle.cms.CMSSignedDataGenerator;
import org.bouncycastle.cms.CMSTypedData;
import org.bouncycastle.cms.jcajce.JcaSignerInfoGeneratorBuilder;
import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.bouncycastle.operator.jcajce.JcaDigestCalculatorProviderBuilder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.security.MessageDigest;
import java.security.cert.X509Certificate;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.Calendar;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
/**
* Serviço de alto nível para Assinatura Digital de Documentos PDF no padrão PAdES (PDF Advanced Electronic Signatures),
* com suporte à aplicação de Selos Visuais estilizados por Temas SVG, posicionamento livre por clique na página e renderização de previews.
*/
public class PdfSigningService {
private static final Logger logger = LoggerFactory.getLogger(PdfSigningService.class);
private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm:ss")
.withZone(ZoneId.systemDefault());
private final CertificateManager certificateManager;
public PdfSigningService(CertificateManager certificateManager) {
this.certificateManager = Objects.requireNonNull(certificateManager, "CertificateManager não pode ser nulo.");
}
/**
* Retorna a quantidade total de páginas contidas no documento PDF.
*/
public static int getPageCount(byte[] pdfBytes) throws IOException {
Objects.requireNonNull(pdfBytes, "Bytes do PDF não podem ser nulos.");
try (PDDocument doc = Loader.loadPDF(pdfBytes)) {
return doc.getNumberOfPages();
}
}
/**
* Renderiza uma página específica do PDF para BufferedImage na resolução DPI informada.
*/
public static BufferedImage renderPageImage(byte[] pdfBytes, int pageIndex0Based, float dpi) throws IOException {
Objects.requireNonNull(pdfBytes, "Bytes do PDF não podem ser nulos.");
try (PDDocument doc = Loader.loadPDF(pdfBytes)) {
if (pageIndex0Based < 0 || pageIndex0Based >= doc.getNumberOfPages()) {
throw new IllegalArgumentException("Índice de página inválido: " + pageIndex0Based + ". Total: " + doc.getNumberOfPages());
}
PDFRenderer renderer = new PDFRenderer(doc);
return renderer.renderImageWithDPI(pageIndex0Based, dpi);
}
}
/**
* Renderiza uma página específica do PDF diretamente para bytes de imagem PNG.
*/
public static byte[] renderPagePngBytes(byte[] pdfBytes, int pageIndex0Based, float dpi) throws IOException {
BufferedImage image = renderPageImage(pdfBytes, pageIndex0Based, dpi);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(image, "PNG", baos);
return baos.toByteArray();
}
/**
* Assina digitalmente um arquivo PDF do sistema de arquivos e grava o resultado no arquivo de destino.
*/
public void signPdfFile(File sourcePdf, File targetPdf, PdfSignatureOptions options) throws Exception {
Objects.requireNonNull(sourcePdf, "Arquivo PDF de origem não pode ser nulo.");
Objects.requireNonNull(targetPdf, "Arquivo PDF de destino não pode ser nulo.");
Objects.requireNonNull(options, "Opções de assinatura não podem ser nulas.");
byte[] inputBytes = Files.readAllBytes(sourcePdf.toPath());
byte[] signedBytes = signPdf(inputBytes, options);
if (targetPdf.getParentFile() != null) {
targetPdf.getParentFile().mkdirs();
}
try (FileOutputStream fos = new FileOutputStream(targetPdf)) {
fos.write(signedBytes);
}
logger.info("PDF assinado com sucesso e salvo em: {}", targetPdf.getAbsolutePath());
}
/**
* Assina digitalmente os bytes de um documento PDF, aplicando o selo visual configurado
* e retornando o PDF assinado completo com envelope PAdES.
*/
public byte[] signPdf(byte[] pdfBytes, PdfSignatureOptions options) throws Exception {
Objects.requireNonNull(pdfBytes, "Bytes do PDF não podem ser nulos.");
Objects.requireNonNull(options, "Opções de assinatura não podem ser nulas.");
// 1. Localiza o certificado e o material criptográfico (chave e cadeia)
Optional<ManagedCertificate> certOpt = certificateManager.findById(options.certificateId());
if (certOpt.isEmpty()) {
certOpt = certificateManager.findByHint(options.certificateId());
}
if (certOpt.isEmpty()) {
throw new CertificateProviderException(
CcspErrorCode.CERTIFICATE_NOT_FOUND,
"Certificado não encontrado no gerenciador para ID: " + options.certificateId()
);
}
ManagedCertificate cert = certOpt.get();
Optional<SigningKeyMaterial> keyMaterialOpt = certificateManager.getKeyMaterial(cert.id(), options.pinOrPassword());
if (keyMaterialOpt.isEmpty()) {
throw new CertificateProviderException(
CcspErrorCode.PRIVATE_KEY_NOT_AVAILABLE,
"Chave privada inacessível para o certificado '" + cert.alias() + "'."
);
}
SigningKeyMaterial keyMaterial = keyMaterialOpt.get();
// 2. Prepara dados do signatário e resumo para o selo visual
String signerDoc = cert.cpf() != null ? "CPF: " + cert.cpf() : (cert.cnpj() != null ? "CNPJ: " + cert.cnpj() : "");
String issuerCn = extractIssuerCn(cert.issuerDn());
String signingTimeStr = DATE_TIME_FORMATTER.format(Instant.now());
MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
byte[] pdfDigest = sha256.digest(pdfBytes);
String hashShort = bytesToHex(pdfDigest).substring(0, 16).toUpperCase();
PdfSealTheme theme = options.sealTheme();
String effectiveExtraInfo = (options.customExtraInfo() != null && !options.customExtraInfo().isBlank())
? options.customExtraInfo().trim()
: theme.formatCustomField();
// 3. Monta dados do selo visual
PdfSealData sealData = new PdfSealData(
cert.commonName() != null ? cert.commonName() : cert.alias(),
signerDoc,
signingTimeStr,
issuerCn,
hashShort,
options.reason(),
options.location(),
options.cpfDisplayMode(),
options.includeDateTime(),
effectiveExtraInfo
);
// 4. Carrega o PDF e estampa o selo se solicitado
byte[] intermediatePdf;
if (options.visibleSeal()) {
byte[] sealPng = PdfSealRenderer.renderToPngBytes(theme, sealData);
intermediatePdf = applyVisualSealToPdf(pdfBytes, sealPng, theme, options);
} else {
intermediatePdf = pdfBytes;
}
// 5. Aplica a assinatura PAdES criptográfica via PDFBox e Bouncy Castle
ByteArrayOutputStream finalOut = new ByteArrayOutputStream();
try (PDDocument doc = Loader.loadPDF(intermediatePdf)) {
PDSignature signature = new PDSignature();
signature.setFilter(PDSignature.FILTER_ADOBE_PPKLITE);
signature.setSubFilter(PDSignature.SUBFILTER_ADBE_PKCS7_DETACHED);
signature.setName(cert.commonName());
signature.setLocation(options.location());
signature.setReason(options.reason());
signature.setSignDate(Calendar.getInstance());
SignatureInterface signer = new PadesBouncyCastleSigner(keyMaterial);
doc.addSignature(signature, signer);
doc.saveIncremental(finalOut);
}
logger.info("Assinatura PAdES concluída com sucesso. Tamanho final: {} bytes", finalOut.size());
return finalOut.toByteArray();
}
private byte[] applyVisualSealToPdf(byte[] pdfBytes, byte[] sealPng, PdfSealTheme theme,
PdfSignatureOptions options) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (PDDocument doc = Loader.loadPDF(pdfBytes)) {
int totalPages = doc.getNumberOfPages();
if (totalPages == 0) {
return pdfBytes;
}
PDImageXObject pdImage = PDImageXObject.createFromByteArray(doc, sealPng, "cybercore_seal");
float sealWidth = theme.width() * 0.72f;
float sealHeight = theme.height() * 0.72f;
float margin = 20f;
// Se coordenadas customizadas foram informadas (clique ou drag & drop na página)
if (options.customX() != null && options.customY() != null) {
int targetPageIdx = totalPages - 1;
if (options.targetPageNumber() != null && options.targetPageNumber() >= 1 && options.targetPageNumber() <= totalPages) {
targetPageIdx = options.targetPageNumber() - 1;
}
PDPage page = doc.getPage(targetPageIdx);
PDRectangle mediaBox = page.getMediaBox();
float pageW = mediaBox.getWidth();
float pageH = mediaBox.getHeight();
float x = Math.max(0, Math.min(options.customX(), pageW - sealWidth));
float y = Math.max(0, Math.min(options.customY(), pageH - sealHeight));
try (PDPageContentStream cs = new PDPageContentStream(doc, page, PDPageContentStream.AppendMode.APPEND, true, true)) {
cs.drawImage(pdImage, x, y, sealWidth, sealHeight);
}
doc.save(baos);
return baos.toByteArray();
}
// Caso contrário, usa posicionamento por enum (BOTTOM_RIGHT, etc.)
int startPage = 0;
int endPage = totalPages - 1;
if (options.pageChoice() == PdfSealPageChoice.FIRST_PAGE) {
endPage = 0;
} else if (options.pageChoice() == PdfSealPageChoice.LAST_PAGE) {
startPage = totalPages - 1;
}
for (int i = startPage; i <= endPage; i++) {
PDPage page = doc.getPage(i);
PDRectangle mediaBox = page.getMediaBox();
float pageW = mediaBox.getWidth();
float pageH = mediaBox.getHeight();
float x;
float y;
switch (options.position()) {
case BOTTOM_LEFT -> {
x = margin;
y = margin;
}
case TOP_RIGHT -> {
x = pageW - sealWidth - margin;
y = pageH - sealHeight - margin;
}
case TOP_LEFT -> {
x = margin;
y = pageH - sealHeight - margin;
}
default -> { // BOTTOM_RIGHT
x = pageW - sealWidth - margin;
y = margin;
}
}
try (PDPageContentStream cs = new PDPageContentStream(doc, page, PDPageContentStream.AppendMode.APPEND, true, true)) {
cs.drawImage(pdImage, x, y, sealWidth, sealHeight);
}
}
doc.save(baos);
}
return baos.toByteArray();
}
private static String bytesToHex(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
private static String extractIssuerCn(String dn) {
if (dn == null) return "ICP-Brasil";
int idx = dn.indexOf("CN=");
if (idx >= 0) {
int end = dn.indexOf(',', idx);
return end > idx ? dn.substring(idx + 3, end).trim() : dn.substring(idx + 3).trim();
}
return dn;
}
/**
* Assinador PAdES CMS/PKCS#7 desacoplado via Bouncy Castle.
*/
private static class PadesBouncyCastleSigner implements SignatureInterface {
private final SigningKeyMaterial keyMaterial;
public PadesBouncyCastleSigner(SigningKeyMaterial keyMaterial) {
this.keyMaterial = Objects.requireNonNull(keyMaterial);
}
@Override
public byte[] sign(InputStream content) throws IOException {
try {
byte[] messageBytes = content.readAllBytes();
CMSSignedDataGenerator gen = new CMSSignedDataGenerator();
ContentSigner contentSigner = new JcaContentSignerBuilder("SHA256withRSA")
.build(keyMaterial.privateKey());
X509Certificate userCert = keyMaterial.certificateChain()[0];
gen.addSignerInfoGenerator(new JcaSignerInfoGeneratorBuilder(
new JcaDigestCalculatorProviderBuilder().build())
.build(contentSigner, userCert));
gen.addCertificates(new JcaCertStore(Arrays.asList(keyMaterial.certificateChain())));
CMSTypedData msg = new CMSProcessableByteArray(messageBytes);
CMSSignedData signedData = gen.generate(msg, false);
return signedData.getEncoded();
} catch (Exception e) {
logger.error("Falha ao gerar assinatura CMS/PKCS#7 para PDF", e);
throw new IOException("Falha criptográfica no Bouncy Castle: " + e.getMessage(), e);
}
}
}
}
@@ -0,0 +1,64 @@
package br.com.cybercore.signer.core.pdf.seal;
/**
* Modo de exibição do CPF do signatário no selo visual de assinatura digital.
* Permite balancear autenticidade documental e privacidade de dados (LGPD).
*/
public enum CpfDisplayMode {
/**
* O CPF não é exibido no selo visual.
*/
NONE("Ocultar CPF"),
/**
* O CPF é exibido parcialmente mascarado (ex: ***.456.789-**).
*/
MASKED("Mascarado (***.456.789-**)"),
/**
* O CPF é exibido integralmente (ex: 123.456.789-00).
*/
FULL("Completo (123.456.789-00)");
private final String description;
CpfDisplayMode(String description) {
this.description = description;
}
public String getDescription() {
return description;
}
/**
* Formata um CPF bruto ou previamente formatado de acordo com este modo.
*
* @param rawCpf CPF a ser formatado (com ou sem pontuação)
* @return String formatada conforme o modo escolhido, ou vazio se não houver CPF
*/
public String formatCpf(String rawCpf) {
if (this == NONE || rawCpf == null || rawCpf.isBlank()) {
return "";
}
String digits = rawCpf.replaceAll("\\D", "");
if (digits.length() != 11) {
// Se não for um CPF de 11 dígitos, retorna mascarado ou o próprio texto
return this == MASKED ? "***.***.***-**" : rawCpf;
}
if (this == MASKED) {
// Mascara os 3 primeiros e os 2 dígitos verificadores: ***.456.789-**
String part2 = digits.substring(3, 6);
String part3 = digits.substring(6, 9);
return "***." + part2 + "." + part3 + "-**";
}
// FULL: 123.456.789-00
return digits.substring(0, 3) + "." +
digits.substring(3, 6) + "." +
digits.substring(6, 9) + "-" +
digits.substring(9, 11);
}
}
@@ -0,0 +1,175 @@
package br.com.cybercore.signer.core.pdf.seal;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Stream;
/**
* Gerenciador de armazenamento em disco para Selos Visuais Personalizados criados ou instalados pelo usuário.
* Persiste cada selo como um arquivo JSON no diretório ~/.cybercore/seals/.
*/
public final class CustomSealStorage {
private static final Logger logger = LoggerFactory.getLogger(CustomSealStorage.class);
private static final String SEALS_DIR_NAME = "seals";
private final Path storageDir;
private final ObjectMapper objectMapper;
public CustomSealStorage() {
this(resolveDefaultStorageDir());
}
public CustomSealStorage(Path storageDir) {
this.storageDir = Objects.requireNonNull(storageDir);
this.objectMapper = new ObjectMapper()
.enable(SerializationFeature.INDENT_OUTPUT);
ensureDirectoryExists();
}
private static Path resolveDefaultStorageDir() {
String userHome = System.getProperty("user.home", ".");
return Path.of(userHome, ".cybercore", SEALS_DIR_NAME);
}
private void ensureDirectoryExists() {
try {
if (!Files.exists(storageDir)) {
Files.createDirectories(storageDir);
}
} catch (IOException e) {
logger.error("Falha ao criar diretório de selos personalizados: {}", storageDir, e);
}
}
public Path getStorageDir() {
return storageDir;
}
/**
* Salva ou atualiza um selo personalizado em disco.
*/
public void saveSeal(PdfSealTheme seal) throws IOException {
Objects.requireNonNull(seal, "Selo não pode ser nulo.");
ensureDirectoryExists();
String safeFileName = sanitizeFileName(seal.id()) + ".json";
Path targetPath = storageDir.resolve(safeFileName);
// Marca como personalizado
PdfSealTheme customSeal = new PdfSealTheme(
seal.id(),
seal.name(),
seal.description(),
seal.width(),
seal.height(),
seal.primaryColor(),
seal.secondaryColor(),
seal.backgroundColor(),
seal.borderColor(),
seal.textColor(),
seal.svgTemplate(),
seal.customHeader(),
seal.customFooter(),
seal.iconStyle(),
seal.defaultCpfMode(),
seal.defaultIncludeDateTime(),
true,
seal.customLogoBase64(),
seal.customFieldLabel(),
seal.customFieldValue()
);
objectMapper.writeValue(targetPath.toFile(), customSeal);
logger.info("Selo personalizado '{}' instalado com sucesso em: {}", seal.id(), targetPath);
}
/**
* Carrega todos os selos personalizados instalados no diretório do usuário.
*/
public List<PdfSealTheme> loadAllCustomSeals() {
List<PdfSealTheme> seals = new ArrayList<>();
if (!Files.exists(storageDir)) {
return seals;
}
try (Stream<Path> stream = Files.list(storageDir)) {
stream.filter(p -> p.toString().toLowerCase().endsWith(".json"))
.forEach(file -> {
try {
PdfSealTheme seal = objectMapper.readValue(file.toFile(), PdfSealTheme.class);
seals.add(seal);
} catch (Exception e) {
logger.warn("Falha ao carregar arquivo de selo {}: {}", file.getFileName(), e.getMessage());
}
});
} catch (IOException e) {
logger.error("Erro ao listar diretório de selos {}", storageDir, e);
}
return seals;
}
/**
* Remove um selo personalizado do disco.
*/
public boolean deleteSeal(String sealId) {
if (sealId == null || sealId.isBlank()) return false;
String safeFileName = sanitizeFileName(sealId) + ".json";
Path targetPath = storageDir.resolve(safeFileName);
try {
boolean deleted = Files.deleteIfExists(targetPath);
if (deleted) {
logger.info("Selo personalizado '{}' removido do disco.", sealId);
}
return deleted;
} catch (IOException e) {
logger.error("Erro ao excluir selo {}: {}", sealId, e.getMessage());
return false;
}
}
/**
* Importa um selo a partir de um arquivo externo (.json) e o instala no diretório do usuário.
*/
public PdfSealTheme importSeal(Path externalFile) throws IOException {
Objects.requireNonNull(externalFile, "Arquivo de importação não pode ser nulo.");
if (!Files.exists(externalFile)) {
throw new IllegalArgumentException("O arquivo de selo especificado não existe: " + externalFile);
}
PdfSealTheme seal = objectMapper.readValue(externalFile.toFile(), PdfSealTheme.class);
saveSeal(seal);
return seal;
}
/**
* Exporta um selo para um arquivo externo de destino.
*/
public void exportSeal(PdfSealTheme seal, Path destinationFile) throws IOException {
Objects.requireNonNull(seal, "Selo não pode ser nulo.");
Objects.requireNonNull(destinationFile, "Destino não pode ser nulo.");
if (destinationFile.getParent() != null && !Files.exists(destinationFile.getParent())) {
Files.createDirectories(destinationFile.getParent());
}
objectMapper.writeValue(destinationFile.toFile(), seal);
logger.info("Selo '{}' exportado com sucesso para: {}", seal.id(), destinationFile);
}
private static String sanitizeFileName(String id) {
return id.replaceAll("[^a-zA-Z0-9._-]", "_");
}
}
@@ -0,0 +1,64 @@
package br.com.cybercore.signer.core.pdf.seal;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
/**
* Metadados e dados do signatário a serem gravados e representados no selo visual da assinatura PDF.
*/
public record PdfSealData(
String signerName,
String identifier,
String signingDate,
String issuer,
String hashSummary,
String reason,
String location,
CpfDisplayMode cpfDisplayMode,
boolean includeDateTime,
String customExtraInfo
) {
private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm:ss (z)")
.withZone(ZoneId.systemDefault());
public PdfSealData {
signerName = (signerName != null && !signerName.isBlank()) ? signerName : "Signatário ICP-Brasil";
identifier = (identifier != null && !identifier.isBlank()) ? identifier : "";
signingDate = (signingDate != null && !signingDate.isBlank()) ? signingDate : FORMATTER.format(Instant.now());
issuer = (issuer != null && !issuer.isBlank()) ? issuer : "Autoridade Certificadora ICP-Brasil";
hashSummary = (hashSummary != null && !hashSummary.isBlank()) ? hashSummary : "";
reason = (reason != null && !reason.isBlank()) ? reason : "Assinatura Digital de Documento";
location = (location != null && !location.isBlank()) ? location : "Brasil";
if (cpfDisplayMode == null) {
cpfDisplayMode = CpfDisplayMode.FULL;
}
}
/**
* Construtor para compatibilidade com assinaturas anteriores a customExtraInfo.
*/
public PdfSealData(String signerName, String identifier, String signingDate, String issuer,
String hashSummary, String reason, String location,
CpfDisplayMode cpfDisplayMode, boolean includeDateTime) {
this(signerName, identifier, signingDate, issuer, hashSummary, reason, location, cpfDisplayMode, includeDateTime, null);
}
/**
* Construtor de compatibilidade para código que não especifica cpfDisplayMode e includeDateTime.
*/
public PdfSealData(String signerName, String identifier, String signingDate, String issuer,
String hashSummary, String reason, String location) {
this(signerName, identifier, signingDate, issuer, hashSummary, reason, location, CpfDisplayMode.FULL, true, null);
}
public static PdfSealData of(String signerName, String identifier, String issuer, String hashSummary) {
return new PdfSealData(signerName, identifier, null, issuer, hashSummary, null, null, CpfDisplayMode.FULL, true);
}
public static PdfSealData of(String signerName, String identifier, String issuer, String hashSummary,
CpfDisplayMode cpfDisplayMode, boolean includeDateTime) {
return new PdfSealData(signerName, identifier, null, issuer, hashSummary, null, null, cpfDisplayMode, includeDateTime);
}
}
@@ -0,0 +1,411 @@
package br.com.cybercore.signer.core.pdf.seal;
import javax.imageio.ImageIO;
import java.awt.BasicStroke;
import java.awt.Color;
import java.awt.Font;
import java.awt.FontMetrics;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.geom.Ellipse2D;
import java.awt.geom.Path2D;
import java.awt.geom.RoundRectangle2D;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.Objects;
/**
* Renderizador de alta fidelidade para Selos Visuais de Assinatura PDF.
* Gera imagens nítidas (300 DPI equivalente) baseadas nos Temas e vetores SVG,
* utilizando exclusivamente a API gráfica padrão Java2D (AWT), sem dependência de JavaFX.
*
* Garante que o nome completo do signatário seja sempre exibido na íntegra,
* além de respeitar as opções de CPF (completo, mascarado ou omitido) e data/hora.
*/
public final class PdfSealRenderer {
private PdfSealRenderer() {
}
/**
* Renderiza o selo visual baseado no tema e dados fornecidos para um BufferedImage em escala 2x (alta nitidez).
*/
public static BufferedImage renderToImage(PdfSealTheme theme, PdfSealData data) {
Objects.requireNonNull(theme, "Tema não pode ser nulo.");
Objects.requireNonNull(data, "Dados da assinatura não podem ser nulos.");
int baseWidth = theme.width();
int baseHeight = theme.height();
// Escala 2x para renderização super-nítida no documento PDF e na interface
int scale = 2;
int imgWidth = baseWidth * scale;
int imgHeight = baseHeight * scale;
BufferedImage image = new BufferedImage(imgWidth, imgHeight, BufferedImage.TYPE_INT_ARGB);
Graphics2D g2d = image.createGraphics();
try {
// Configuração de anti-aliasing e renderização de alta qualidade
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
g2d.setRenderingHint(RenderingHints.KEY_FRACTIONALMETRICS, RenderingHints.VALUE_FRACTIONALMETRICS_ON);
g2d.setRenderingHint(RenderingHints.KEY_STROKE_CONTROL, RenderingHints.VALUE_STROKE_PURE);
g2d.scale(scale, scale);
Color primaryColor = parseColor(theme.primaryColor(), new Color(13, 71, 161));
Color secondaryColor = parseColor(theme.secondaryColor(), new Color(2, 132, 199));
Color bgColor = parseColor(theme.backgroundColor(), Color.WHITE);
Color borderColor = parseColor(theme.borderColor(), new Color(203, 213, 225));
Color textColor = parseColor(theme.textColor(), new Color(15, 23, 42));
// 1. Fundo do Selo com cantos arredondados
g2d.setColor(bgColor);
g2d.fill(new RoundRectangle2D.Float(1, 1, baseWidth - 2, baseHeight - 2, 8, 8));
// 2. Borda externa
g2d.setColor(borderColor);
g2d.setStroke(new BasicStroke(1.2f));
g2d.draw(new RoundRectangle2D.Float(1, 1, baseWidth - 2, baseHeight - 2, 8, 8));
// 3. Tarja/Acento lateral do tema
g2d.setColor(primaryColor);
g2d.fill(new RoundRectangle2D.Float(1, 1, 5, baseHeight - 2, 3, 3));
if ("gov-br".equalsIgnoreCase(theme.id()) || "star".equalsIgnoreCase(theme.iconStyle())) {
g2d.setColor(secondaryColor);
g2d.fillRect(6, 1, 3, baseHeight - 2);
}
// 4. Emblema à Esquerda (Logo Customizada em Imagem ou Ícone Vetorial)
if (theme.customLogoBase64() != null && !theme.customLogoBase64().isBlank()) {
drawCustomLogo(g2d, theme.customLogoBase64(), 10, 14, 36, baseHeight - 28);
} else {
String effectiveIcon = (theme.iconStyle() != null && !theme.iconStyle().isBlank()) ? theme.iconStyle() : theme.id();
drawThemeIcon(g2d, effectiveIcon, primaryColor, secondaryColor);
}
// 5. Informações Textuais da Assinatura (Focado no Titular, sem dados técnicos/emissor)
int textX = 52;
int textWidthAvailable = baseWidth - textX - 12;
String formattedDoc = formatSignerDoc(data);
String customInfo = (data.customExtraInfo() != null && !data.customExtraInfo().isBlank())
? data.customExtraInfo().trim()
: theme.formatCustomField();
String identityLine;
if (!formattedDoc.isBlank() && !customInfo.isBlank()) {
identityLine = formattedDoc + " • " + customInfo;
} else if (!formattedDoc.isBlank()) {
identityLine = formattedDoc;
} else {
identityLine = customInfo;
}
boolean hasIdentity = !identityLine.isBlank();
boolean hasDate = data.includeDateTime();
String fullName = data.signerName() != null ? data.signerName().trim() : "Signatário ICP-Brasil";
String headerText = resolveHeaderText(theme);
String footerText = resolveFooterText(theme, null);
if (hasIdentity && hasDate) {
// Layout com 5 linhas: Cabeçalho, Nome, Identificação (CPF/Matrícula), Data, Rodapé
g2d.setColor(primaryColor);
g2d.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 9));
g2d.drawString(headerText, textX, 16);
g2d.setColor(textColor);
drawFullSignerName(g2d, fullName, textX, 33, textWidthAvailable, 13.0f);
g2d.setColor(new Color(51, 65, 85));
g2d.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 10));
drawFittedText(g2d, identityLine, textX, 47, textWidthAvailable, 10.0f);
g2d.drawString("Data: " + data.signingDate(), textX, 60);
g2d.setColor(secondaryColor);
g2d.setFont(new Font(Font.SANS_SERIF, Font.ITALIC, 8));
g2d.drawString(footerText, textX, 73);
} else if (hasIdentity || hasDate) {
// Layout com 4 linhas: Cabeçalho, Nome, (Identificação ou Data), Rodapé
g2d.setColor(primaryColor);
g2d.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 10));
g2d.drawString(headerText, textX, 17);
g2d.setColor(textColor);
drawFullSignerName(g2d, fullName, textX, 36, textWidthAvailable, 13.5f);
g2d.setColor(new Color(51, 65, 85));
g2d.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 10));
String singleLineInfo = hasIdentity ? identityLine : ("Data: " + data.signingDate());
drawFittedText(g2d, singleLineInfo, textX, 53, textWidthAvailable, 10.0f);
g2d.setColor(secondaryColor);
g2d.setFont(new Font(Font.SANS_SERIF, Font.ITALIC, 8));
g2d.drawString(footerText, textX, 71);
} else {
// Layout com 3 linhas: Cabeçalho, Nome em Destaque Máximo, Rodapé
g2d.setColor(primaryColor);
g2d.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 10));
g2d.drawString(headerText, textX, 19);
g2d.setColor(textColor);
drawFullSignerName(g2d, fullName, textX, 44, textWidthAvailable, 14.5f);
g2d.setColor(secondaryColor);
g2d.setFont(new Font(Font.SANS_SERIF, Font.ITALIC, 9));
g2d.drawString(footerText, textX, 68);
}
} finally {
g2d.dispose();
}
return image;
}
/**
* Desenha o logotipo personalizado fornecido em Base64, preservando o aspecto proporcional
* e aplicando anti-aliasing e interpolação bilinear de alta fidelidade.
*/
private static void drawCustomLogo(Graphics2D g2d, String base64Data, int x, int y, int maxWidth, int maxHeight) {
try {
String cleanBase64 = base64Data;
int commaIdx = cleanBase64.indexOf(",");
if (commaIdx >= 0 && cleanBase64.substring(0, commaIdx).contains("base64")) {
cleanBase64 = cleanBase64.substring(commaIdx + 1);
}
byte[] bytes = java.util.Base64.getDecoder().decode(cleanBase64.trim());
BufferedImage logoImg = ImageIO.read(new java.io.ByteArrayInputStream(bytes));
if (logoImg != null) {
int originalW = logoImg.getWidth();
int originalH = logoImg.getHeight();
if (originalW > 0 && originalH > 0) {
double scaleW = (double) maxWidth / originalW;
double scaleH = (double) maxHeight / originalH;
double scale = Math.min(scaleW, scaleH);
int targetW = Math.max(1, (int) Math.round(originalW * scale));
int targetH = Math.max(1, (int) Math.round(originalH * scale));
int posX = x + (maxWidth - targetW) / 2;
int posY = y + (maxHeight - targetH) / 2;
g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
g2d.drawImage(logoImg, posX, posY, targetW, targetH, null);
return;
}
}
} catch (Exception ignored) {
// Se houver falha na leitura do logo, recorre ao escudo padrão
}
drawThemeIcon(g2d, "shield", new Color(13, 71, 161), new Color(2, 132, 199));
}
/**
* Desenha o nome completo do signatário sem cortes ou truncamentos.
* Ajusta a fonte gradativamente para caber perfeitamente na largura do selo.
*/
private static void drawFullSignerName(Graphics2D g2d, String fullName, int x, int y, int maxWidth, float preferredSize) {
float fontSize = preferredSize;
Font font = new Font(Font.SANS_SERIF, Font.BOLD, Math.round(fontSize));
FontMetrics fm = g2d.getFontMetrics(font);
// Se o nome for longo, reduz o tamanho da fonte até caber perfeitamente
while (fm.stringWidth(fullName) > maxWidth && fontSize > 8.0f) {
fontSize -= 0.5f;
font = new Font(Font.SANS_SERIF, Font.BOLD, Math.round(fontSize));
fm = g2d.getFontMetrics(font);
}
g2d.setFont(font);
g2d.drawString(fullName, x, y);
}
/**
* Desenha texto informativo secundário ajustando suavemente a fonte caso exceda a largura.
*/
private static void drawFittedText(Graphics2D g2d, String text, int x, int y, int maxWidth, float baseSize) {
if (text == null || text.isBlank()) return;
float fontSize = baseSize;
Font font = new Font(Font.SANS_SERIF, Font.PLAIN, Math.round(fontSize));
FontMetrics fm = g2d.getFontMetrics(font);
while (fm.stringWidth(text) > maxWidth && fontSize > 7.5f) {
fontSize -= 0.5f;
font = new Font(Font.SANS_SERIF, Font.PLAIN, Math.round(fontSize));
fm = g2d.getFontMetrics(font);
}
g2d.setFont(font);
g2d.drawString(text, x, y);
}
/**
* Formata os dados de documento (CPF/CNPJ) de acordo com o modo de exibição configurado.
*/
private static String formatSignerDoc(PdfSealData data) {
CpfDisplayMode mode = data.cpfDisplayMode() != null ? data.cpfDisplayMode() : CpfDisplayMode.FULL;
String rawDoc = data.identifier() != null ? data.identifier() : "";
if (mode == CpfDisplayMode.NONE) {
// Se for CPF, oculta. Se for CNPJ, pode manter ou formatar.
if (rawDoc.toUpperCase().contains("CPF")) {
return "";
}
return rawDoc;
}
if (rawDoc.toUpperCase().contains("CPF")) {
String digits = rawDoc.replaceAll("\\D", "");
String formatted = mode.formatCpf(digits);
return formatted.isBlank() ? "" : "CPF: " + formatted;
}
return rawDoc;
}
/**
* Renderiza o selo visual e o exporta diretamente como array de bytes PNG.
*/
public static byte[] renderToPngBytes(PdfSealTheme theme, PdfSealData data) throws IOException {
BufferedImage img = renderToImage(theme, data);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(img, "PNG", baos);
return baos.toByteArray();
}
private static void drawThemeIcon(Graphics2D g2d, String iconStyleOrId, Color primary, Color secondary) {
g2d.translate(14, 18);
String style = (iconStyleOrId != null) ? iconStyleOrId.toLowerCase() : "shield";
if ("cybercore-modern".equals(style) || "crystal".equals(style)) {
// Cristal CyberCore Vetorial
Path2D.Float crystal = new Path2D.Float();
crystal.moveTo(16, 2);
crystal.lineTo(28, 9);
crystal.lineTo(28, 25);
crystal.lineTo(16, 32);
crystal.lineTo(4, 25);
crystal.lineTo(4, 9);
crystal.closePath();
g2d.setColor(new Color(primary.getRed(), primary.getGreen(), primary.getBlue(), 35));
g2d.fill(crystal);
g2d.setColor(primary);
g2d.setStroke(new BasicStroke(1.5f));
g2d.draw(crystal);
g2d.setColor(secondary);
g2d.drawLine(16, 2, 16, 32);
g2d.setColor(primary);
g2d.drawLine(4, 17, 28, 17);
} else if ("minimal-clean".equals(style) || "notary".equals(style) || "cartorio".equals(style)) {
// Carimbo notarial sóbrio
g2d.setColor(primary);
g2d.setStroke(new BasicStroke(1.5f));
g2d.draw(new Ellipse2D.Float(2, 2, 28, 28));
g2d.setStroke(new BasicStroke(0.8f, BasicStroke.CAP_BUTT, BasicStroke.JOIN_MITER, 10.0f, new float[]{2.0f, 2.0f}, 0.0f));
g2d.draw(new Ellipse2D.Float(5, 5, 22, 22));
// Checkmark
g2d.setStroke(new BasicStroke(1.8f));
g2d.drawLine(10, 16, 14, 20);
g2d.drawLine(14, 20, 22, 12);
} else if ("gov-br".equals(style) || "star".equals(style) || "governo".equals(style)) {
// Brasão estelar estilizado
g2d.setColor(new Color(primary.getRed(), primary.getGreen(), primary.getBlue(), 30));
g2d.fill(new Ellipse2D.Float(2, 2, 28, 28));
g2d.setColor(primary);
g2d.setStroke(new BasicStroke(1.4f));
g2d.draw(new Ellipse2D.Float(2, 2, 28, 28));
// Estrela interna
Path2D.Float star = new Path2D.Float();
star.moveTo(16, 6);
star.lineTo(19, 13);
star.lineTo(26, 13);
star.lineTo(20, 17);
star.lineTo(22, 24);
star.lineTo(16, 20);
star.lineTo(10, 24);
star.lineTo(12, 17);
star.lineTo(6, 13);
star.lineTo(13, 13);
star.closePath();
g2d.setColor(secondary);
g2d.fill(star);
} else {
// Padrão: Escudo Criptográfico Oficial com Cadeado
Path2D.Float shield = new Path2D.Float();
shield.moveTo(16, 2);
shield.lineTo(28, 6);
shield.lineTo(28, 16);
shield.curveTo(28, 25, 22, 31, 16, 34);
shield.curveTo(10, 31, 4, 25, 4, 16);
shield.lineTo(4, 6);
shield.closePath();
g2d.setColor(new Color(primary.getRed(), primary.getGreen(), primary.getBlue(), 35));
g2d.fill(shield);
g2d.setColor(primary);
g2d.setStroke(new BasicStroke(1.5f));
g2d.draw(shield);
// Cadeado
g2d.setColor(primary);
g2d.fillRect(11, 15, 10, 8);
g2d.setStroke(new BasicStroke(1.3f));
g2d.drawArc(12, 9, 8, 8, 0, 180);
g2d.setColor(Color.WHITE);
g2d.fillOval(15, 17, 2, 2);
}
g2d.translate(-14, -18);
}
private static String resolveHeaderText(PdfSealTheme theme) {
if (theme.customHeader() != null && !theme.customHeader().isBlank()) {
return theme.customHeader().toUpperCase();
}
return switch (theme.id().toLowerCase()) {
case "cybercore-modern" -> "CYBERCORE SIGNER • AUTENTICAÇÃO DIGITAL";
case "minimal-clean" -> "DOCUMENTO ASSINADO DIGITALMENTE";
case "gov-br" -> "GOV.BR • CONFORMIDADE MP Nº 2.200-2/2001";
default -> "ASSINADO DIGITALMENTE • ICP-BRASIL";
};
}
private static String resolveFooterText(PdfSealTheme theme, String hash) {
if (theme.customFooter() != null && !theme.customFooter().isBlank()) {
return theme.customFooter();
}
if (hash != null && !hash.isBlank()) {
return "Hash Criptográfico: " + hash;
}
return switch (theme.id().toLowerCase()) {
case "cybercore-modern" -> "Autenticidade assegurada por chave criptográfica local";
case "minimal-clean" -> "Assinatura qualificada padrão ICP-Brasil";
case "gov-br" -> "Validade jurídica plena conforme MP nº 2.200-2/2001";
default -> "Conformidade ICP-Brasil e validade jurídica plena";
};
}
private static Color parseColor(String hex, Color def) {
if (hex == null || !hex.startsWith("#")) return def;
try {
return Color.decode(hex);
} catch (Exception e) {
return def;
}
}
}
@@ -0,0 +1,135 @@
package br.com.cybercore.signer.core.pdf.seal;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import java.util.Objects;
/**
* Modelo de configuração e identidade visual de um Tema de Selo de Assinatura PDF.
* Permite definir paleta de cores, dimensões, templates, textos personalizados e preferências de exibição.
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record PdfSealTheme(
String id,
String name,
String description,
int width,
int height,
String primaryColor,
String secondaryColor,
String backgroundColor,
String borderColor,
String textColor,
String svgTemplate,
String customHeader,
String customFooter,
String iconStyle,
CpfDisplayMode defaultCpfMode,
boolean defaultIncludeDateTime,
boolean isCustom,
String customLogoBase64,
String customFieldLabel,
String customFieldValue
) {
public PdfSealTheme {
Objects.requireNonNull(id, "ID do tema não pode ser nulo.");
Objects.requireNonNull(name, "Nome do tema não pode ser nulo.");
if (width <= 0) width = 320;
if (height <= 0) height = 82;
if (primaryColor == null) primaryColor = "#0d47a1";
if (secondaryColor == null) secondaryColor = "#0284c7";
if (backgroundColor == null) backgroundColor = "#ffffff";
if (borderColor == null) borderColor = "#cbd5e1";
if (textColor == null) textColor = "#0f172a";
if (iconStyle == null || iconStyle.isBlank()) iconStyle = "shield";
if (defaultCpfMode == null) defaultCpfMode = CpfDisplayMode.FULL;
}
/**
* Construtor para temas customizados com logotipo Base64, sem campos extras.
*/
public PdfSealTheme(String id, String name, String description, int width, int height,
String primaryColor, String secondaryColor, String backgroundColor,
String borderColor, String textColor, String svgTemplate,
String customHeader, String customFooter, String iconStyle,
CpfDisplayMode defaultCpfMode, boolean defaultIncludeDateTime, boolean isCustom,
String customLogoBase64) {
this(id, name, description, width, height, primaryColor, secondaryColor, backgroundColor,
borderColor, textColor, svgTemplate, customHeader, customFooter, iconStyle,
defaultCpfMode, defaultIncludeDateTime, isCustom, customLogoBase64, null, null);
}
/**
* Construtor para temas customizados sem logo personalizada em imagem.
*/
public PdfSealTheme(String id, String name, String description, int width, int height,
String primaryColor, String secondaryColor, String backgroundColor,
String borderColor, String textColor, String svgTemplate,
String customHeader, String customFooter, String iconStyle,
CpfDisplayMode defaultCpfMode, boolean defaultIncludeDateTime, boolean isCustom) {
this(id, name, description, width, height, primaryColor, secondaryColor, backgroundColor,
borderColor, textColor, svgTemplate, customHeader, customFooter, iconStyle,
defaultCpfMode, defaultIncludeDateTime, isCustom, null, null, null);
}
/**
* Construtor legado para os temas embutidos do sistema.
*/
public PdfSealTheme(String id, String name, String description, int width, int height,
String primaryColor, String secondaryColor, String backgroundColor,
String borderColor, String textColor, String svgTemplate) {
this(id, name, description, width, height, primaryColor, secondaryColor, backgroundColor,
borderColor, textColor, svgTemplate, null, null, "shield", CpfDisplayMode.FULL, true, false, null, null, null);
}
/**
* Formata o campo customizado (ex: "Matrícula: 104829" ou "Cargo: Auditor Fiscal").
*/
public String formatCustomField() {
boolean hasLabel = customFieldLabel != null && !customFieldLabel.isBlank();
boolean hasValue = customFieldValue != null && !customFieldValue.isBlank();
if (hasLabel && hasValue) {
String label = customFieldLabel.trim();
if (!label.endsWith(":")) label += ":";
return label + " " + customFieldValue.trim();
} else if (hasValue) {
return customFieldValue.trim();
} else if (hasLabel) {
return customFieldLabel.trim();
}
return "";
}
/**
* Gera o código SVG final substituindo os marcadores pelos dados reais da assinatura.
*/
public String renderSvg(PdfSealData data) {
if (svgTemplate == null || svgTemplate.isBlank()) {
return "";
}
return svgTemplate
.replace("${THEME_PRIMARY}", primaryColor)
.replace("${THEME_SECONDARY}", secondaryColor)
.replace("${THEME_BG}", backgroundColor)
.replace("${THEME_BORDER}", borderColor)
.replace("${THEME_TEXT}", textColor)
.replace("${SIGNER_NAME}", escapeXml(data.signerName()))
.replace("${SIGNER_DOC}", escapeXml(data.identifier()))
.replace("${SIGNING_DATE}", escapeXml(data.signingDate()))
.replace("${CERT_ISSUER}", escapeXml(data.issuer()))
.replace("${HASH_SUMMARY}", escapeXml(data.hashSummary()))
.replace("${REASON}", escapeXml(data.reason()))
.replace("${LOCATION}", escapeXml(data.location()));
}
private static String escapeXml(String text) {
if (text == null) return "";
return text
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&apos;");
}
}
@@ -0,0 +1,240 @@
package br.com.cybercore.signer.core.pdf.seal;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
/**
* Registro central de temas de selos visuais para assinatura PDF.
* Inclui os temas padrão do sistema e permite instalar, gerenciar e persistir dinamicamente
* temas personalizados de selo criados ou importados pelo usuário.
*/
public final class PdfSealThemeRegistry {
private static final Logger logger = LoggerFactory.getLogger(PdfSealThemeRegistry.class);
private static final Map<String, PdfSealTheme> THEMES = new ConcurrentHashMap<>();
private static final CustomSealStorage STORAGE = new CustomSealStorage();
public static final String DEFAULT_THEME_ID = "icp-brasil";
static {
registerBuiltInThemes();
loadCustomThemes();
}
private PdfSealThemeRegistry() {
}
private static void registerBuiltInThemes() {
// 1. Tema ICP-Brasil Institucional
registerTheme(new PdfSealTheme(
"icp-brasil",
"ICP-Brasil Institucional",
"Estilo oficial corporativo com escudo de segurança vetorial e dados completos do titular",
320,
82,
"#0d47a1",
"#0284c7",
"#ffffff",
"#93c5fd",
"#0f172a",
"""
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 82" width="320" height="82">
<rect x="1" y="1" width="318" height="80" rx="6" fill="${THEME_BG}" stroke="${THEME_BORDER}" stroke-width="1.5"/>
<rect x="1" y="1" width="6" height="80" rx="3" fill="${THEME_PRIMARY}"/>
<!-- Escudo Criptográfico Vetorial -->
<g transform="translate(14, 18)">
<path d="M18 2 L32 7 V18 C32 28 25 36 18 39 C11 36 4 28 4 18 V7 Z" fill="${THEME_PRIMARY}" opacity="0.12"/>
<path d="M18 2 L32 7 V18 C32 28 25 36 18 39 C11 36 4 28 4 18 V7 Z" fill="none" stroke="${THEME_PRIMARY}" stroke-width="2"/>
<!-- Cadeado -->
<rect x="12" y="17" width="12" height="10" rx="2" fill="${THEME_PRIMARY}"/>
<path d="M14 17 V13 C14 10.8 15.8 9 18 9 C20.2 9 22 10.8 22 13 V17" fill="none" stroke="${THEME_PRIMARY}" stroke-width="1.8"/>
<circle cx="18" cy="22" r="1.5" fill="#ffffff"/>
</g>
<!-- Dados da Assinatura -->
<text x="56" y="17" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="8.5" font-weight="bold" fill="${THEME_PRIMARY}" letter-spacing="0.5">ASSINADO DIGITALMENTE • ICP-BRASIL</text>
<text x="56" y="32" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="12" font-weight="bold" fill="${THEME_TEXT}">${SIGNER_NAME}</text>
<text x="56" y="46" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="8.5" fill="#475569">Doc: ${SIGNER_DOC} • AC: ${CERT_ISSUER}</text>
<text x="56" y="59" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="8.5" fill="#475569">Data/Hora: ${SIGNING_DATE}</text>
<text x="56" y="72" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="7.5" fill="${THEME_SECONDARY}">Verificação criptográfica válida e com fé pública</text>
</svg>
"""
));
// 2. Tema CyberCore Moderno
registerTheme(new PdfSealTheme(
"cybercore-modern",
"CyberCore Moderno",
"Identidade tecnológica com cristal vetorial CyberCore e visual ciano/esmeralda",
320,
82,
"#0284c7",
"#10b981",
"#ffffff",
"#38bdf8",
"#0f172a",
"""
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 82" width="320" height="82">
<rect x="1" y="1" width="318" height="80" rx="8" fill="${THEME_BG}" stroke="${THEME_BORDER}" stroke-width="1.5"/>
<rect x="1" y="1" width="5" height="80" rx="2.5" fill="${THEME_SECONDARY}"/>
<!-- Cristal CyberCore Vetorial -->
<g transform="translate(14, 20)">
<polygon points="18,2 32,10 32,28 18,36 4,28 4,10" fill="${THEME_PRIMARY}" opacity="0.15"/>
<polygon points="18,2 32,10 32,28 18,36 4,28 4,10" fill="none" stroke="${THEME_PRIMARY}" stroke-width="1.8"/>
<line x1="18" y1="2" x2="18" y2="36" stroke="${THEME_SECONDARY}" stroke-width="1.2"/>
<line x1="4" y1="19" x2="32" y2="19" stroke="${THEME_PRIMARY}" stroke-width="1.2"/>
</g>
<!-- Dados da Assinatura -->
<text x="56" y="17" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="8.5" font-weight="bold" fill="${THEME_PRIMARY}">CYBERCORE SIGNER • ASSINATURA ELETRÔNICA</text>
<text x="56" y="32" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="12" font-weight="bold" fill="${THEME_TEXT}">${SIGNER_NAME}</text>
<text x="56" y="46" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="8.5" fill="#475569">${SIGNER_DOC} • ${CERT_ISSUER}</text>
<text x="56" y="59" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="8.5" fill="#475569">Autenticado em: ${SIGNING_DATE}</text>
<text x="56" y="72" font-family="monospace" font-size="7.5" fill="${THEME_SECONDARY}">Hash: ${HASH_SUMMARY}</text>
</svg>
"""
));
// 3. Tema Minimalista Clássico
registerTheme(new PdfSealTheme(
"minimal-clean",
"Minimalista Clássico",
"Design monocromático sóbrio em tons de grafite, ideal para contratos e certidões",
300,
76,
"#334155",
"#64748b",
"#fafafa",
"#cbd5e1",
"#1e293b",
"""
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 76" width="300" height="76">
<rect x="1" y="1" width="298" height="74" rx="4" fill="${THEME_BG}" stroke="${THEME_BORDER}" stroke-width="1"/>
<!-- Ícone de Carimbo Notarial -->
<g transform="translate(12, 18)">
<circle cx="18" cy="18" r="16" fill="none" stroke="${THEME_PRIMARY}" stroke-width="1.5"/>
<circle cx="18" cy="18" r="13" fill="none" stroke="${THEME_PRIMARY}" stroke-width="0.8" stroke-dasharray="2,2"/>
<path d="M12 18 L16 22 L24 14" fill="none" stroke="${THEME_PRIMARY}" stroke-width="2"/>
</g>
<!-- Dados da Assinatura -->
<text x="52" y="17" font-family="Georgia, serif" font-size="9" font-weight="bold" fill="${THEME_PRIMARY}">Assinado Digitalmente</text>
<text x="52" y="32" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="11.5" font-weight="bold" fill="${THEME_TEXT}">${SIGNER_NAME}</text>
<text x="52" y="46" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="8" fill="#475569">${SIGNER_DOC} • ${CERT_ISSUER}</text>
<text x="52" y="58" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="8" fill="#475569">${SIGNING_DATE}</text>
<text x="52" y="69" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="7.5" fill="#64748b">Conforme MP 2.200-2/2001 e ICP-Brasil</text>
</svg>
"""
));
// 4. Tema Governamental (Gov.br / ITI)
registerTheme(new PdfSealTheme(
"gov-br",
"Governamental (Padrão Gov.br / ITI)",
"Faixa verde e ouro com declaração expressa de validade pela MP nº 2.200-2/2001",
320,
82,
"#047857",
"#eab308",
"#ffffff",
"#a7f3d0",
"#064e3b",
"""
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 82" width="320" height="82">
<rect x="1" y="1" width="318" height="80" rx="4" fill="${THEME_BG}" stroke="${THEME_BORDER}" stroke-width="1.5"/>
<rect x="1" y="1" width="6" height="80" rx="2" fill="${THEME_PRIMARY}"/>
<rect x="7" y="1" width="3" height="80" fill="${THEME_SECONDARY}"/>
<!-- Brasão Estilizado -->
<g transform="translate(16, 20)">
<circle cx="17" cy="18" r="15" fill="${THEME_PRIMARY}" opacity="0.1"/>
<circle cx="17" cy="18" r="15" fill="none" stroke="${THEME_PRIMARY}" stroke-width="1.5"/>
<path d="M17 6 L20 14 L28 14 L22 19 L24 27 L17 22 L10 27 L12 19 L6 14 L14 14 Z" fill="${THEME_SECONDARY}"/>
</g>
<!-- Dados da Assinatura -->
<text x="56" y="17" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="8.5" font-weight="bold" fill="${THEME_PRIMARY}">GOV.BR • DOCUMENTO ASSINADO DIGITALMENTE</text>
<text x="56" y="32" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="12" font-weight="bold" fill="${THEME_TEXT}">${SIGNER_NAME}</text>
<text x="56" y="46" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="8.5" fill="#065f46">Doc: ${SIGNER_DOC} • AC: ${CERT_ISSUER}</text>
<text x="56" y="59" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="8.5" fill="#475569">Data/Hora: ${SIGNING_DATE}</text>
<text x="56" y="72" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="7.5" fill="#047857">Validade jurídica assegurada pela MP nº 2.200-2/2001</text>
</svg>
"""
));
}
/**
* Carrega os temas personalizados armazenados no disco.
*/
public static void loadCustomThemes() {
try {
List<PdfSealTheme> customs = STORAGE.loadAllCustomSeals();
for (PdfSealTheme theme : customs) {
THEMES.put(theme.id().toLowerCase(), theme);
}
logger.info("Carregados {} temas de selos personalizados do disco.", customs.size());
} catch (Exception e) {
logger.warn("Não foi possível carregar temas personalizados: {}", e.getMessage());
}
}
public static void registerTheme(PdfSealTheme theme) {
Objects.requireNonNull(theme, "Tema não pode ser nulo.");
THEMES.put(theme.id().toLowerCase(), theme);
}
/**
* Instala um tema personalizado, salvando-o no disco e registrando-o para uso imediato.
*/
public static void installCustomTheme(PdfSealTheme theme) throws IOException {
Objects.requireNonNull(theme, "Tema não pode ser nulo.");
STORAGE.saveSeal(theme);
registerTheme(theme);
}
/**
* Remove um tema personalizado do disco e do registro.
* Não permite remover temas padrão do sistema.
*/
public static boolean uninstallCustomTheme(String themeId) {
if (themeId == null) return false;
String idLower = themeId.toLowerCase();
// Protege temas padrão
if (idLower.equals("icp-brasil") || idLower.equals("cybercore-modern") ||
idLower.equals("minimal-clean") || idLower.equals("gov-br")) {
throw new IllegalArgumentException("Não é permitido excluir os temas de selo pré-instalados do sistema.");
}
boolean deleted = STORAGE.deleteSeal(idLower);
THEMES.remove(idLower);
return deleted;
}
public static Optional<PdfSealTheme> findById(String id) {
if (id == null) return Optional.empty();
return Optional.ofNullable(THEMES.get(id.toLowerCase()));
}
public static Optional<PdfSealTheme> getTheme(String id) {
return findById(id);
}
public static PdfSealTheme getDefaultTheme() {
return THEMES.getOrDefault(DEFAULT_THEME_ID, THEMES.values().iterator().next());
}
public static List<PdfSealTheme> listThemes() {
List<PdfSealTheme> list = new ArrayList<>(THEMES.values());
return Collections.unmodifiableList(list);
}
public static CustomSealStorage getStorage() {
return STORAGE;
}
}
@@ -0,0 +1,35 @@
package br.com.cybercore.signer.core.protocol;
/**
* Códigos de erro padronizados do protocolo CyberCore Signing Protocol (CCSP/1).
*/
public enum CcspErrorCode {
CERTIFICATE_NOT_FOUND("Certificado especificado não foi encontrado."),
CERTIFICATE_EXPIRED("O certificado selecionado está expirado."),
CERTIFICATE_INVALID("O certificado é inválido ou está corrompido."),
PRIVATE_KEY_NOT_AVAILABLE("Chave privada inacessível ou não disponível para a operação."),
TOKEN_NOT_FOUND("Nenhum token ou leitor de smart card compatível foi encontrado."),
TOKEN_REMOVED("O token ou smart card foi desconectado durante a operação."),
PIN_REQUIRED("PIN obrigatório para desbloqueio do dispositivo."),
PIN_INCORRECT("PIN informado é incorreto."),
PKCS11_ERROR("Falha na comunicação com o módulo PKCS#11 do dispositivo."),
SIGNATURE_FAILED("Falha ao gerar a assinatura digital."),
APPLICATION_NOT_AUTHORIZED("Aplicação web não possui autorização para solicitar assinaturas."),
REQUEST_EXPIRED("A requisição de assinatura expirou."),
REPLAY_DETECTED("Requisição duplicada detectada (replay attack prevenido)."),
INVALID_ORIGIN("Origem da requisição não é permitida para esta aplicação."),
UNSUPPORTED_ALGORITHM("Algoritmo de assinatura não suportado."),
USER_CANCELLED("Operação cancelada pelo usuário."),
PROVIDER_NOT_AVAILABLE("O provedor criptográfico necessário não está disponível."),
COMMUNICATION_ERROR("Erro interno de comunicação no agente local.");
private final String defaultMessage;
CcspErrorCode(String defaultMessage) {
this.defaultMessage = defaultMessage;
}
public String getDefaultMessage() {
return defaultMessage;
}
}
@@ -0,0 +1,39 @@
package br.com.cybercore.signer.core.protocol;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
/**
* Operações suportadas pelo protocolo CCSP.
*/
public enum CcspOperation {
SIGN("sign"),
AUTHENTICATE("authenticate"),
LIST_CERTIFICATES("list_certificates"),
STATUS("status"),
PING("ping");
private final String value;
CcspOperation(String value) {
this.value = value;
}
@JsonValue
public String getValue() {
return value;
}
@JsonCreator
public static CcspOperation fromValue(String value) {
if (value == null) {
return null;
}
for (CcspOperation op : values()) {
if (op.value.equalsIgnoreCase(value.trim())) {
return op;
}
}
throw new IllegalArgumentException("Operação CCSP desconhecida: " + value);
}
}
@@ -0,0 +1,96 @@
package br.com.cybercore.signer.core.protocol;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.io.Serializable;
import java.time.Instant;
/**
* Mensagem de requisição do CyberCore Signing Protocol (CCSP/1).
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonIgnoreProperties(ignoreUnknown = true)
public record CcspRequest(
@JsonProperty("version")
String version,
@JsonProperty("request_id")
String requestId,
@JsonProperty("application_id")
String applicationId,
@JsonProperty("operation")
String operation,
@JsonProperty("algorithm")
String algorithm,
@JsonProperty("digest")
String digest,
@JsonProperty("certificate_hint")
String certificateHint,
@JsonProperty("expires_at")
String expiresAt,
@JsonProperty("timestamp")
Long timestamp,
@JsonProperty("nonce")
String nonce,
@JsonProperty("signature_type")
String signatureType,
@JsonProperty("document_name")
String documentName,
@JsonProperty("document_type")
String documentType,
@JsonProperty("purpose")
String purpose,
@JsonProperty("challenge")
String challenge
) implements Serializable {
public static final String PROTOCOL_VERSION = "1";
public CcspRequest(
String version, String requestId, String applicationId, String operation,
String algorithm, String digest, String certificateHint, String expiresAt,
Long timestamp, String nonce, String signatureType
) {
this(version, requestId, applicationId, operation, algorithm, digest,
certificateHint, expiresAt, timestamp, nonce, signatureType,
null, null, null, null);
}
public boolean isVersionSupported() {
return PROTOCOL_VERSION.equals(version);
}
public boolean isAuthentication() {
return "authenticate".equalsIgnoreCase(operation)
|| "AUTHENTICATION".equalsIgnoreCase(purpose)
|| "AUTH_CHALLENGE".equalsIgnoreCase(documentType)
|| (challenge != null && !challenge.isBlank());
}
public boolean isExpired() {
if (expiresAt == null || expiresAt.isBlank()) {
return false;
}
try {
Instant expiry = Instant.parse(expiresAt);
return Instant.now().isAfter(expiry);
} catch (Exception e) {
return false;
}
}
}
@@ -0,0 +1,136 @@
package br.com.cybercore.signer.core.protocol;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.io.Serializable;
import java.util.Collections;
import java.util.List;
/**
* Mensagem de resposta do CyberCore Signing Protocol (CCSP/1).
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public record CcspResponse(
@JsonProperty("version")
String version,
@JsonProperty("request_id")
String requestId,
@JsonProperty("status")
String status,
@JsonProperty("signature")
String signature,
@JsonProperty("certificate")
String certificate,
@JsonProperty("certificate_chain")
List<String> certificateChain,
@JsonProperty("algorithm")
String algorithm,
@JsonProperty("error_code")
String errorCode,
@JsonProperty("message")
String message,
@JsonProperty("subject_name")
String subjectName,
@JsonProperty("subject_cpf_cnpj")
String subjectCpfCnpj,
@JsonProperty("document_name")
String documentName,
@JsonProperty("document_type")
String documentType
) implements Serializable {
public CcspResponse {
certificateChain = certificateChain != null ? List.copyOf(certificateChain) : null;
}
@Override
public List<String> certificateChain() {
return certificateChain != null ? Collections.unmodifiableList(certificateChain) : null;
}
public static final String STATUS_SIGNED = "signed";
public static final String STATUS_AUTHENTICATED = "authenticated";
public static final String STATUS_SUCCESS = "success";
public static final String STATUS_ERROR = "error";
public static CcspResponse success(String requestId, String signatureBase64, String certBase64,
List<String> chain, String algorithm) {
return success(requestId, signatureBase64, certBase64, chain, algorithm, null, null, null, null);
}
public static CcspResponse success(String requestId, String signatureBase64, String certBase64,
List<String> chain, String algorithm,
String subjectName, String subjectCpfCnpj,
String documentName, String documentType) {
return new CcspResponse(
CcspRequest.PROTOCOL_VERSION,
requestId,
STATUS_SIGNED,
signatureBase64,
certBase64,
chain,
algorithm,
null,
null,
subjectName,
subjectCpfCnpj,
documentName,
documentType
);
}
public static CcspResponse authenticated(String requestId, String signatureBase64, String certBase64,
List<String> chain, String algorithm,
String subjectName, String subjectCpfCnpj) {
return new CcspResponse(
CcspRequest.PROTOCOL_VERSION,
requestId,
STATUS_AUTHENTICATED,
signatureBase64,
certBase64,
chain,
algorithm,
null,
null,
subjectName,
subjectCpfCnpj,
null,
"AUTH_CHALLENGE"
);
}
public static CcspResponse error(String requestId, CcspErrorCode errorCode, String message) {
return new CcspResponse(
CcspRequest.PROTOCOL_VERSION,
requestId,
STATUS_ERROR,
null,
null,
null,
null,
errorCode.name(),
message != null ? message : errorCode.getDefaultMessage(),
null,
null,
null,
null
);
}
public static CcspResponse error(String requestId, CcspErrorCode errorCode) {
return error(requestId, errorCode, errorCode.getDefaultMessage());
}
}
@@ -0,0 +1,550 @@
package br.com.cybercore.signer.core.protocol;
import br.com.cybercore.signer.core.crypto.DigestAlgorithm;
import br.com.cybercore.signer.core.crypto.SigningService;
import br.com.cybercore.signer.core.crypto.X509CertificateUtils;
import br.com.cybercore.signer.core.domain.ManagedCertificate;
import br.com.cybercore.signer.core.domain.SigningContext;
import br.com.cybercore.signer.core.domain.SigningResult;
import java.security.MessageDigest;
import br.com.cybercore.signer.core.provider.CertificateManager;
import br.com.cybercore.signer.core.provider.CertificateProviderException;
import br.com.cybercore.signer.core.security.ApplicationRegistry;
import br.com.cybercore.signer.core.security.AuthorizedApplication;
import br.com.cybercore.signer.core.security.NonceManager;
import br.com.cybercore.signer.core.security.OriginValidator;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Base64;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
/**
* Servidor HTTP local do CyberCore Signing Protocol (CCSP/1).
* Vinculado exclusivamente ao loopback 127.0.0.1 para comunicação segura com navegadores web.
*/
public class CcspServer {
private static final Logger logger = LoggerFactory.getLogger(CcspServer.class);
public static final int DEFAULT_PORT = 21325;
public static final String BIND_HOST = "127.0.0.1";
private final int port;
private final CertificateManager certificateManager;
private final SigningService signingService;
private final ApplicationRegistry applicationRegistry;
private final NonceManager nonceManager;
private final OriginValidator originValidator;
private final CcspUserPromptListener promptListener;
private final ObjectMapper objectMapper;
private final ReentrantLock signLock = new ReentrantLock(true); // Concorrência FIFO
private HttpServer httpServer;
private ExecutorService executor;
private volatile boolean running = false;
public CcspServer(int port,
CertificateManager certificateManager,
SigningService signingService,
ApplicationRegistry applicationRegistry,
CcspUserPromptListener promptListener) {
this.port = port;
this.certificateManager = Objects.requireNonNull(certificateManager, "certificateManager cannot be null");
this.signingService = Objects.requireNonNull(signingService, "signingService cannot be null");
this.applicationRegistry = applicationRegistry != null ? applicationRegistry : ApplicationRegistry.getInstance();
this.promptListener = promptListener;
this.nonceManager = new NonceManager();
this.originValidator = new OriginValidator();
this.objectMapper = new ObjectMapper()
.registerModule(new JavaTimeModule())
.setSerializationInclusion(JsonInclude.Include.NON_NULL);
}
public CcspServer(CertificateManager certificateManager,
SigningService signingService,
CcspUserPromptListener promptListener) {
this(DEFAULT_PORT, certificateManager, signingService, ApplicationRegistry.getInstance(), promptListener);
}
/**
* Inicia o servidor local na porta configurada.
*/
public synchronized void start() throws IOException {
if (running) {
return;
}
InetSocketAddress address = new InetSocketAddress(BIND_HOST, port);
httpServer = HttpServer.create(address, 50);
executor = Executors.newFixedThreadPool(8, r -> {
Thread t = new Thread(r, "ccsp-http-worker");
t.setDaemon(true);
return t;
});
httpServer.setExecutor(executor);
// Registra endpoints do protocolo CCSP/1
httpServer.createContext("/ccsp/v1/status", new StatusHandler());
httpServer.createContext("/ccsp/v1/certificates", new CertificatesHandler());
httpServer.createContext("/ccsp/v1/sign", new SignHandler());
httpServer.createContext("/ccsp/v1/authenticate", new SignHandler());
httpServer.createContext("/ccsp/v1/authorize", new AuthorizeHandler());
httpServer.start();
running = true;
logger.info("Servidor local CCSP/1 iniciado com sucesso em http://{}:{}", BIND_HOST, getPort());
}
/**
* Encerra a execução do servidor local.
*/
public synchronized void stop() {
if (!running) {
return;
}
if (httpServer != null) {
httpServer.stop(1);
httpServer = null;
}
if (executor != null) {
executor.shutdownNow();
executor = null;
}
running = false;
logger.info("Servidor local CCSP/1 finalizado.");
}
public boolean isRunning() {
return running;
}
public int getPort() {
if (httpServer != null) {
return httpServer.getAddress().getPort();
}
return port;
}
// =========================================================================
// Handlers HTTP
// =========================================================================
private class StatusHandler implements HttpHandler {
@Override
public void handle(HttpExchange exchange) throws IOException {
if (handleCors(exchange)) return;
if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) {
sendErrorResponse(exchange, 405, "METHOD_NOT_ALLOWED", "Apenas GET é suportado.");
return;
}
Map<String, Object> resp = new HashMap<>();
resp.put("version", CcspRequest.PROTOCOL_VERSION);
resp.put("status", "ready");
resp.put("agent", "CyberCore Signer");
resp.put("agent_version", "0.1.0");
resp.put("os", System.getProperty("os.name"));
resp.put("timestamp", Instant.now().toString());
sendJsonResponse(exchange, 200, resp);
}
}
private class CertificatesHandler implements HttpHandler {
@Override
public void handle(HttpExchange exchange) throws IOException {
if (handleCors(exchange)) return;
if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) {
sendErrorResponse(exchange, 405, "METHOD_NOT_ALLOWED", "Apenas GET é suportado.");
return;
}
String origin = getOrigin(exchange);
String appId = exchange.getRequestHeaders().getFirst("X-CyberCore-AppId");
if (appId == null || appId.isBlank()) {
URI uri = exchange.getRequestURI();
appId = getQueryParam(uri, "application_id");
}
// Verifica autorização se app_id e origin forem informados
if (appId != null && !appId.isBlank()) {
boolean authorized = applicationRegistry.isAuthorized(appId, origin);
if (!authorized) {
boolean approved = promptApplicationAuthorization(origin, "Aplicação Web", appId);
if (!approved) {
sendErrorResponse(exchange, 403, CcspErrorCode.APPLICATION_NOT_AUTHORIZED.name(),
"Aplicação não autorizada pelo usuário.");
return;
}
}
}
List<ManagedCertificate> certs = certificateManager.getAvailableCertificates();
if (certs.isEmpty()) {
certs = certificateManager.discoverCertificates();
}
List<Map<String, Object>> certDtoList = certs.stream().map(c -> {
Map<String, Object> dto = new HashMap<>();
dto.put("id", c.id());
dto.put("alias", c.alias());
dto.put("common_name", c.commonName());
dto.put("subject_dn", c.subjectDn());
dto.put("issuer_dn", c.issuerDn());
dto.put("type", c.type().name());
dto.put("provider_id", c.providerId());
dto.put("device_name", c.hardwareDeviceName());
dto.put("valid_from", c.notBefore() != null ? c.notBefore().toString() : null);
dto.put("valid_to", c.notAfter() != null ? c.notAfter().toString() : null);
dto.put("serial_number", c.serialNumber());
dto.put("can_sign", c.canSign());
dto.put("status", c.status().name());
return dto;
}).toList();
Map<String, Object> resp = new HashMap<>();
resp.put("version", CcspRequest.PROTOCOL_VERSION);
resp.put("status", "success");
resp.put("certificates", certDtoList);
sendJsonResponse(exchange, 200, resp);
}
}
private class AuthorizeHandler implements HttpHandler {
@Override
public void handle(HttpExchange exchange) throws IOException {
if (handleCors(exchange)) return;
if (!"POST".equalsIgnoreCase(exchange.getRequestMethod())) {
sendErrorResponse(exchange, 405, "METHOD_NOT_ALLOWED", "Apenas POST é suportado.");
return;
}
String origin = getOrigin(exchange);
try (InputStream is = exchange.getRequestBody()) {
@SuppressWarnings("unchecked")
Map<String, String> body = objectMapper.readValue(is, Map.class);
String appId = body.get("application_id");
String appName = body.getOrDefault("name", "Aplicação Web");
if (appId == null || appId.isBlank()) {
sendErrorResponse(exchange, 400, "BAD_REQUEST", "application_id é obrigatório.");
return;
}
if (applicationRegistry.isAuthorized(appId, origin)) {
Map<String, Object> resp = Map.of(
"version", CcspRequest.PROTOCOL_VERSION,
"status", "authorized",
"application_id", appId
);
sendJsonResponse(exchange, 200, resp);
return;
}
boolean approved = promptApplicationAuthorization(origin, appName, appId);
if (approved) {
Map<String, Object> resp = Map.of(
"version", CcspRequest.PROTOCOL_VERSION,
"status", "authorized",
"application_id", appId
);
sendJsonResponse(exchange, 200, resp);
} else {
sendErrorResponse(exchange, 403, CcspErrorCode.APPLICATION_NOT_AUTHORIZED.name(),
"Autorização negada pelo usuário.");
}
} catch (Exception e) {
sendErrorResponse(exchange, 400, "BAD_REQUEST", "Payload JSON inválido: " + e.getMessage());
}
}
}
private class SignHandler implements HttpHandler {
@Override
public void handle(HttpExchange exchange) throws IOException {
if (handleCors(exchange)) return;
if (!"POST".equalsIgnoreCase(exchange.getRequestMethod())) {
sendErrorResponse(exchange, 405, "METHOD_NOT_ALLOWED", "Apenas POST é suportado.");
return;
}
String origin = getOrigin(exchange);
CcspRequest request;
try (InputStream is = exchange.getRequestBody()) {
request = objectMapper.readValue(is, CcspRequest.class);
} catch (Exception e) {
sendCcspError(exchange, 400, null, CcspErrorCode.SIGNATURE_FAILED, "Payload de requisição inválido.");
return;
}
// 1. Validação de versão
if (!request.isVersionSupported()) {
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.COMMUNICATION_ERROR,
"Versão do protocolo não suportada: " + request.version());
return;
}
// 2. Validação de expiração
if (request.isExpired()) {
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.REQUEST_EXPIRED,
"A requisição de assinatura expirou.");
return;
}
// 3. Validação anti-replay (Nonce)
if (request.nonce() != null && !request.nonce().isBlank()) {
boolean accepted = nonceManager.registerNonce(request.nonce());
if (!accepted) {
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.REPLAY_DETECTED,
"Requisição duplicada detectada (replay prevenido).");
return;
}
}
// 4. Validação de autorização da aplicação
String appId = request.applicationId();
if (appId == null || appId.isBlank()) {
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.APPLICATION_NOT_AUTHORIZED,
"application_id é obrigatório.");
return;
}
if (!applicationRegistry.isAuthorized(appId, origin)) {
boolean approved = promptApplicationAuthorization(origin, "Aplicação Web", appId);
if (!approved) {
sendCcspError(exchange, 403, request.requestId(), CcspErrorCode.APPLICATION_NOT_AUTHORIZED,
"Aplicação não autorizada pelo usuário.");
return;
}
}
// 5. Validação de digest / challenge
byte[] digestBytes;
if (request.digest() != null && !request.digest().isBlank()) {
try {
digestBytes = Base64.getDecoder().decode(request.digest());
} catch (Exception e) {
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.SIGNATURE_FAILED,
"Digest em Base64 inválido.");
return;
}
} else if (request.challenge() != null && !request.challenge().isBlank()) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
digestBytes = md.digest(request.challenge().getBytes(StandardCharsets.UTF_8));
} catch (Exception e) {
sendCcspError(exchange, 500, request.requestId(), CcspErrorCode.SIGNATURE_FAILED,
"Falha ao gerar digest do desafio: " + e.getMessage());
return;
}
} else {
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.SIGNATURE_FAILED,
"É obrigatório informar 'digest' ou 'challenge' para a operação.");
return;
}
// 6. Resolução do certificado
Optional<ManagedCertificate> certOpt = certificateManager.findByHint(request.certificateHint());
if (certOpt.isEmpty()) {
sendCcspError(exchange, 404, request.requestId(), CcspErrorCode.CERTIFICATE_NOT_FOUND,
"Nenhum certificado correspondente encontrado.");
return;
}
ManagedCertificate cert = certOpt.get();
// 7. Confirmação do usuário na UI (se listener configurado)
if (promptListener != null) {
try {
CompletableFuture<Boolean> approvalFuture = promptListener.requestSignatureConfirmation(request, cert);
Boolean confirmed = approvalFuture.get(60, TimeUnit.SECONDS);
if (!Boolean.TRUE.equals(confirmed)) {
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.USER_CANCELLED,
"Operação de assinatura cancelada pelo usuário.");
return;
}
} catch (Exception e) {
logger.warn("Falha ou timeout na confirmação visual de assinatura: {}", e.getMessage());
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.USER_CANCELLED,
"Confirmação da assinatura expirou ou foi cancelada.");
return;
}
}
// 8. Assinatura com controle sequencial FIFO
signLock.lock();
try {
SigningContext ctx = new SigningContext(
request.requestId(),
request.applicationId(),
digestBytes,
request.algorithm() != null ? request.algorithm() : "SHA256withRSA",
cert.id(),
Instant.now()
);
SigningResult result = signingService.sign(ctx);
String sigBase64 = Base64.getEncoder().encodeToString(result.signatureBytes());
String subjectName = X509CertificateUtils.extractHolderName(cert.commonName());
String cpfCnpj = cert.cpf() != null ? cert.cpf() : cert.cnpj();
boolean isAuth = request.isAuthentication() || exchange.getRequestURI().getPath().endsWith("/authenticate");
CcspResponse response = isAuth
? CcspResponse.authenticated(
request.requestId(),
sigBase64,
result.certificateBase64(),
result.certificateChainBase64(),
result.algorithm(),
subjectName,
cpfCnpj
)
: CcspResponse.success(
request.requestId(),
sigBase64,
result.certificateBase64(),
result.certificateChainBase64(),
result.algorithm(),
subjectName,
cpfCnpj,
request.documentName(),
request.documentType()
);
sendJsonResponse(exchange, 200, response);
} catch (CertificateProviderException cpe) {
sendCcspError(exchange, 400, request.requestId(), cpe.getErrorCode(), cpe.getMessage());
} catch (Exception e) {
logger.error("Erro inesperado na assinatura: {}", e.getMessage(), e);
sendCcspError(exchange, 500, request.requestId(), CcspErrorCode.SIGNATURE_FAILED,
"Erro interno: " + e.getMessage());
} finally {
signLock.unlock();
}
}
}
// =========================================================================
// Utilitários de Segurança e HTTP
// =========================================================================
private boolean promptApplicationAuthorization(String origin, String appName, String appId) {
if (promptListener == null) {
// Em testes unitários sem UI, aprova e registra
applicationRegistry.register(new AuthorizedApplication(
appId, appName, origin != null ? Set.of(origin) : Set.of("*"), true, Instant.now()
));
return true;
}
try {
CompletableFuture<Boolean> future = promptListener.requestApplicationAuthorization(origin, appName, appId);
Boolean approved = future.get(30, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(approved)) {
applicationRegistry.register(new AuthorizedApplication(
appId, appName, origin != null ? Set.of(origin) : Set.of("*"), true, Instant.now()
));
return true;
}
} catch (Exception e) {
logger.warn("Timeout ou erro na autorização da aplicação: {}", e.getMessage());
}
return false;
}
private boolean handleCors(HttpExchange exchange) throws IOException {
String origin = getOrigin(exchange);
if (origin == null || origin.isBlank()) {
origin = "*";
}
exchange.getResponseHeaders().set("Access-Control-Allow-Origin", origin);
exchange.getResponseHeaders().set("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
exchange.getResponseHeaders().set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-CyberCore-AppId, X-CyberCore-Nonce");
exchange.getResponseHeaders().set("Access-Control-Max-Age", "86400");
if ("OPTIONS".equalsIgnoreCase(exchange.getRequestMethod())) {
exchange.sendResponseHeaders(204, -1);
exchange.close();
return true;
}
return false;
}
private String getOrigin(HttpExchange exchange) {
String origin = exchange.getRequestHeaders().getFirst("Origin");
if (origin == null || origin.isBlank()) {
origin = exchange.getRequestHeaders().getFirst("Referer");
}
return origin;
}
private String getQueryParam(URI uri, String key) {
if (uri.getQuery() == null) return null;
for (String param : uri.getQuery().split("&")) {
String[] pair = param.split("=");
if (pair.length == 2 && pair[0].equalsIgnoreCase(key)) {
return pair[1];
}
}
return null;
}
private void sendJsonResponse(HttpExchange exchange, int statusCode, Object data) throws IOException {
byte[] bytes = objectMapper.writeValueAsBytes(data);
exchange.getResponseHeaders().set("Content-Type", "application/json; charset=UTF-8");
exchange.sendResponseHeaders(statusCode, bytes.length);
try (OutputStream os = exchange.getResponseBody()) {
os.write(bytes);
}
}
private void sendErrorResponse(HttpExchange exchange, int httpStatus, String code, String message) throws IOException {
Map<String, Object> err = Map.of(
"version", CcspRequest.PROTOCOL_VERSION,
"status", "error",
"error_code", code,
"message", message
);
sendJsonResponse(exchange, httpStatus, err);
}
private void sendCcspError(HttpExchange exchange, int httpStatus, String reqId, CcspErrorCode code, String msg) throws IOException {
CcspResponse err = CcspResponse.error(reqId, code, msg != null ? msg : code.getDefaultMessage());
sendJsonResponse(exchange, httpStatus, err);
}
}
@@ -0,0 +1,31 @@
package br.com.cybercore.signer.core.protocol;
import br.com.cybercore.signer.core.domain.ManagedCertificate;
import java.util.concurrent.CompletableFuture;
/**
* Interface de interação com o usuário para autorização de aplicações e confirmação de assinaturas.
* Permite desacoplar o servidor HTTP de comunicação da interface gráfica desktop (JavaFX).
*/
public interface CcspUserPromptListener {
/**
* Solicita ao usuário autorização para que uma nova aplicação web se comunique com o agente.
*
* @param origin Origem HTTP/HTTPS da aplicação (ex: https://sistema.exemplo.com.br).
* @param appName Nome amigável da aplicação solicitante.
* @param applicationId ID único da aplicação.
* @return Future completado com {@code true} se o usuário autorizar; {@code false} caso recuse.
*/
CompletableFuture<Boolean> requestApplicationAuthorization(String origin, String appName, String applicationId);
/**
* Solicita ao usuário confirmação visual explícita antes de realizar uma assinatura digital.
*
* @param request Requisição CCSP de assinatura recebida.
* @param certificate Certificado digital selecionado para a operação.
* @return Future completado com {@code true} se o usuário confirmar; {@code false} caso cancele.
*/
CompletableFuture<Boolean> requestSignatureConfirmation(CcspRequest request, ManagedCertificate certificate);
}
@@ -0,0 +1,194 @@
package br.com.cybercore.signer.core.provider;
import br.com.cybercore.signer.core.domain.CertificateStatus;
import br.com.cybercore.signer.core.domain.ManagedCertificate;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
/**
* Gerenciador central de certificados digitais do CyberCore Signer.
* Responsável pela descoberta em múltiplos provedores (Windows Certificate Store, A1, PKCS#11),
* validação de integridade, mapeamento de provedores e seleção segura para operações de assinatura.
*/
public class CertificateManager {
private static final Logger logger = LoggerFactory.getLogger(CertificateManager.class);
private final List<CertificateProvider> providers = new CopyOnWriteArrayList<>();
private final Map<String, CertificateProvider> certificateToProviderMap = new ConcurrentHashMap<>();
private final Map<String, ManagedCertificate> cachedCertificates = new ConcurrentHashMap<>();
public CertificateManager() {
}
/**
* Cria e inicializa uma instância padrão do CertificateManager com os provedores nativos da plataforma.
*/
public static CertificateManager createDefault() {
CertificateManager manager = new CertificateManager();
manager.registerProvider(new WindowsCertificateStoreProvider());
manager.registerProvider(new Pkcs12CertificateProvider());
manager.registerProvider(new Pkcs11CertificateProvider());
return manager;
}
/**
* Registra um provedor de certificados no ecossistema do agente.
*/
public void registerProvider(CertificateProvider provider) {
Objects.requireNonNull(provider, "Provedor não pode ser nulo.");
// Evita duplicidade por providerId
providers.removeIf(p -> p.getProviderId().equalsIgnoreCase(provider.getProviderId()));
providers.add(provider);
logger.info("Provedor registrado no CertificateManager: {} [{}] (disponível={})",
provider.getDisplayName(), provider.getProviderId(), provider.isAvailable());
}
/**
* Remove um provedor previamente registrado.
*/
public void unregisterProvider(String providerId) {
if (providerId != null) {
providers.removeIf(p -> p.getProviderId().equalsIgnoreCase(providerId));
certificateToProviderMap.values().removeIf(p -> p.getProviderId().equalsIgnoreCase(providerId));
}
}
public List<CertificateProvider> getProviders() {
return Collections.unmodifiableList(providers);
}
/**
* Executa a descoberta de certificados em todos os provedores operacionais.
* Consolida os resultados, atualiza o mapeamento de provedores e ordena por relevância/disponibilidade.
*/
public synchronized List<ManagedCertificate> discoverCertificates() {
logger.info("Iniciando descoberta de certificados digitais nos provedores ativos...");
certificateToProviderMap.clear();
cachedCertificates.clear();
List<ManagedCertificate> discovered = new ArrayList<>();
for (CertificateProvider provider : providers) {
if (!provider.isAvailable()) {
logger.debug("Provedor '{}' não disponível no momento. Ignorando na descoberta.", provider.getProviderId());
continue;
}
try {
List<ManagedCertificate> certs = provider.listCertificates();
logger.info("Provedor '{}' retornou {} certificado(s).", provider.getProviderId(), certs.size());
for (ManagedCertificate cert : certs) {
certificateToProviderMap.put(cert.id(), provider);
cachedCertificates.put(cert.id(), cert);
discovered.add(cert);
}
} catch (Exception e) {
logger.error("Erro ao listar certificados do provedor '{}': {}", provider.getProviderId(), e.getMessage());
}
}
// Ordenação: certificados disponíveis primeiro, depois por nome do titular
discovered.sort(Comparator
.comparing((ManagedCertificate c) -> c.status() == CertificateStatus.AVAILABLE ? 0 : 1)
.thenComparing(ManagedCertificate::commonName, String.CASE_INSENSITIVE_ORDER));
logger.info("Descoberta concluída: {} certificado(s) total no agente.", discovered.size());
return Collections.unmodifiableList(discovered);
}
/**
* Retorna o provedor responsável pelo certificado com o ID informado.
*/
public CertificateProvider getProviderForCertificate(String certId) {
if (certId == null) {
return null;
}
return certificateToProviderMap.get(certId);
}
/**
* Obtém o material criptográfico (chave privada e cadeia X.509) do certificado especificado.
*/
public Optional<br.com.cybercore.signer.core.domain.SigningKeyMaterial> getKeyMaterial(String certId, char[] pinOrPassword) throws CertificateProviderException {
CertificateProvider provider = getProviderForCertificate(certId);
if (provider == null) {
return Optional.empty();
}
return provider.getKeyMaterial(certId, pinOrPassword);
}
/**
* Busca um certificado previamente descoberto pelo seu identificador único.
*/
public Optional<ManagedCertificate> findById(String certId) {
if (certId == null) {
return Optional.empty();
}
return Optional.ofNullable(cachedCertificates.get(certId));
}
/**
* Localiza um certificado usando hints flexíveis: id, alias, CPF, CNPJ ou impressões digitais SHA-1/SHA-256.
*/
public Optional<ManagedCertificate> findByHint(String hint) {
if (hint == null || hint.isBlank()) {
return Optional.empty();
}
String cleanHint = hint.trim();
// 1. Busca por ID direto
ManagedCertificate direct = cachedCertificates.get(cleanHint);
if (direct != null) {
return Optional.of(direct);
}
// 2. Busca por alias, CPF, CNPJ ou fingerprints
for (ManagedCertificate cert : cachedCertificates.values()) {
if (cleanHint.equalsIgnoreCase(cert.alias())
|| cleanHint.equalsIgnoreCase(cert.cpf())
|| cleanHint.equalsIgnoreCase(cert.cnpj())
|| cleanHint.equalsIgnoreCase(cert.sha1Fingerprint())
|| cleanHint.equalsIgnoreCase(cert.sha256Fingerprint())
|| cleanHint.equalsIgnoreCase(cert.serialNumber())) {
return Optional.of(cert);
}
}
return Optional.empty();
}
/**
* Retorna a lista dos certificados em cache que possuem status AVAILABLE (com chave privada e vigentes).
*/
public List<ManagedCertificate> getAvailableCertificates() {
return cachedCertificates.values().stream()
.filter(c -> c.status() == CertificateStatus.AVAILABLE)
.toList();
}
/**
* Solicita recarregamento de todos os provedores cadastrados.
*/
public void refreshAll() {
for (CertificateProvider provider : providers) {
try {
provider.refresh();
} catch (Exception e) {
logger.warn("Erro ao recarregar provedor '{}': {}", provider.getProviderId(), e.getMessage());
}
}
discoverCertificates();
}
}
@@ -0,0 +1,61 @@
package br.com.cybercore.signer.core.provider;
import br.com.cybercore.signer.core.domain.ManagedCertificate;
import br.com.cybercore.signer.core.domain.SigningContext;
import br.com.cybercore.signer.core.domain.SigningResult;
import java.util.List;
/**
* Contrato base para provedores de descoberta e assinatura com certificados digitais.
* Permite plugar diferentes backends (A1 em arquivo, Windows-MY, PKCS#11, Mock)
* sem acoplar o núcleo do aplicativo a fabricantes ou dispositivos específicos.
*/
public interface CertificateProvider {
/**
* Identificador único do provedor (ex: "provider-a1", "provider-win-store", "provider-pkcs11").
*/
String getProviderId();
/**
* Nome legível para apresentação na interface e diagnósticos.
*/
String getDisplayName();
/**
* Tipo do provedor.
*/
ProviderType getType();
/**
* Indica se o provedor está disponível e operacional no sistema atual.
*/
boolean isAvailable();
/**
* Lista os certificados disponíveis atualmente através deste provedor.
*/
List<ManagedCertificate> listCertificates() throws CertificateProviderException;
/**
* Executa a assinatura do digest contido no contexto.
* A chave privada NUNCA deve sair do provedor/dispositivo.
*/
SigningResult sign(SigningContext context) throws CertificateProviderException;
/**
* Retorna o material criptográfico (chave privada e cadeia) do certificado indicado,
* permitindo que operações que montam envelopes como PAdES ou XMLDSig operem localmente.
*/
default java.util.Optional<br.com.cybercore.signer.core.domain.SigningKeyMaterial> getKeyMaterial(String certificateId, char[] pinOrPassword) throws CertificateProviderException {
return java.util.Optional.empty();
}
/**
* Solicita recarga ou reescaneamento de certificados e dispositivos.
*/
default void refresh() {
// Implementação padrão opcional
}
}
@@ -0,0 +1,32 @@
package br.com.cybercore.signer.core.provider;
import br.com.cybercore.signer.core.protocol.CcspErrorCode;
/**
* Exceção disparada por falhas nos provedores de certificado.
* Carrega obrigatoriamente um código de erro padronizado do CCSP.
*/
public class CertificateProviderException extends Exception {
private static final long serialVersionUID = 1L;
private final CcspErrorCode errorCode;
public CertificateProviderException(CcspErrorCode errorCode, String message) {
super(message);
this.errorCode = errorCode != null ? errorCode : CcspErrorCode.SIGNATURE_FAILED;
}
public CertificateProviderException(CcspErrorCode errorCode, String message, Throwable cause) {
super(message, cause);
this.errorCode = errorCode != null ? errorCode : CcspErrorCode.SIGNATURE_FAILED;
}
public CertificateProviderException(CcspErrorCode errorCode) {
this(errorCode, errorCode != null ? errorCode.getDefaultMessage() : CcspErrorCode.SIGNATURE_FAILED.getDefaultMessage());
}
public CcspErrorCode getErrorCode() {
return errorCode;
}
}
@@ -0,0 +1,247 @@
package br.com.cybercore.signer.core.provider;
import br.com.cybercore.signer.core.crypto.DigestAlgorithm;
import br.com.cybercore.signer.core.crypto.X509CertificateUtils;
import br.com.cybercore.signer.core.domain.CertificateType;
import br.com.cybercore.signer.core.domain.ManagedCertificate;
import br.com.cybercore.signer.core.domain.SigningContext;
import br.com.cybercore.signer.core.domain.SigningResult;
import br.com.cybercore.signer.core.protocol.CcspErrorCode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.PrivateKey;
import java.security.Provider;
import java.security.Security;
import java.security.Signature;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Collections;
import java.util.Enumeration;
import java.util.List;
import java.util.Objects;
/**
* Provedor de certificados A3 via interface padrão PKCS#11 (tokens USB, smart cards e leitores).
* Utiliza o provider SunPKCS11 integrado à JVM para comunicação com middlewares dos fabricantes.
* Nunca persiste PIN em disco e nunca o transmite para servidores web.
*/
public class Pkcs11CertificateProvider implements CertificateProvider {
private static final Logger logger = LoggerFactory.getLogger(Pkcs11CertificateProvider.class);
private static final String PROVIDER_ID = "provider-pkcs11";
/**
* Bibliotecas PKCS#11 comuns de middleware para tokens e smartcards no Windows e Linux.
*/
private static final String[] WELL_KNOWN_LIBRARIES = {
// Windows
"C:/Windows/System32/eps2003csp11.dll", // SafeNet / eToken
"C:/Windows/System32/aetpkss1.dll", // SafeSign AET
"C:/Windows/System32/acospkcs11.dll", // ACO / Giesecke & Devrient
"C:/Windows/System32/opensc-pkcs11.dll",// OpenSC Windows
// Linux
"/usr/lib/x86_64-linux-gnu/opensc-pkcs11.so",
"/usr/lib/opensc-pkcs11.so",
"/usr/lib/libaetpkss.so"
};
private Provider sunPkcs11Provider;
private KeyStore keyStore;
private String detectedLibraryPath;
public Pkcs11CertificateProvider() {
detectAndInitialize();
}
private synchronized void detectAndInitialize() {
for (String libPath : WELL_KNOWN_LIBRARIES) {
File f = new File(libPath);
if (f.exists() && f.canRead()) {
detectedLibraryPath = libPath;
logger.info("Middleware PKCS#11 detectado no sistema: {}", libPath);
break;
}
}
}
/**
* Permite registrar explicitamente um provider PKCS#11 já inicializado (ex: via SunPKCS11 com PIN do usuário).
*/
public synchronized void setProvider(Provider provider, KeyStore keyStore) {
this.sunPkcs11Provider = provider;
this.keyStore = keyStore;
}
@Override
public String getProviderId() {
return PROVIDER_ID;
}
@Override
public String getDisplayName() {
return "Hardware PKCS#11 (Smart Card / Token USB)";
}
@Override
public ProviderType getType() {
return ProviderType.A3_PKCS11;
}
@Override
public synchronized boolean isAvailable() {
return (detectedLibraryPath != null) || (keyStore != null);
}
public synchronized String getDetectedLibraryPath() {
return detectedLibraryPath;
}
@Override
public synchronized List<ManagedCertificate> listCertificates() throws CertificateProviderException {
if (keyStore == null) {
// Se o middleware estiver detectado mas o token ainda não estiver autenticado via PIN
return Collections.emptyList();
}
try {
List<ManagedCertificate> certificates = new ArrayList<>();
Enumeration<String> aliases = keyStore.aliases();
while (aliases.hasMoreElements()) {
String alias = aliases.nextElement();
boolean isKeyEntry = keyStore.isKeyEntry(alias);
Certificate cert = keyStore.getCertificate(alias);
if (!(cert instanceof X509Certificate x509)) {
continue;
}
String certId = "p11-" + Integer.toHexString(alias.hashCode());
ManagedCertificate mc = X509CertificateUtils.toManagedCertificate(
certId,
alias,
x509,
CertificateType.A3,
isKeyEntry,
PROVIDER_ID,
"Token USB / Smart Card (PKCS#11)"
);
certificates.add(mc);
}
return Collections.unmodifiableList(certificates);
} catch (KeyStoreException e) {
throw new CertificateProviderException(
CcspErrorCode.PKCS11_ERROR,
"Falha ao ler certificados do dispositivo PKCS#11: " + e.getMessage(),
e
);
}
}
@Override
public synchronized SigningResult sign(SigningContext context) throws CertificateProviderException {
Objects.requireNonNull(context, "Contexto de assinatura não pode ser nulo.");
if (keyStore == null) {
throw new CertificateProviderException(
CcspErrorCode.TOKEN_NOT_FOUND,
"Dispositivo PKCS#11 não conectado ou não autenticado com PIN."
);
}
DigestAlgorithm alg = DigestAlgorithm.fromName(context.algorithm());
alg.validateDigestLength(context.digest());
String targetAlias = resolveAlias(context.certificateHint());
if (targetAlias == null) {
throw new CertificateProviderException(
CcspErrorCode.CERTIFICATE_NOT_FOUND,
"Certificado não encontrado no dispositivo PKCS#11."
);
}
try {
PrivateKey privateKey = (PrivateKey) keyStore.getKey(targetAlias, null);
if (privateKey == null) {
throw new CertificateProviderException(
CcspErrorCode.PRIVATE_KEY_NOT_AVAILABLE,
"Chave privada inacessível no dispositivo PKCS#11."
);
}
Certificate[] chain = keyStore.getCertificateChain(targetAlias);
if (chain == null || chain.length == 0) {
Certificate singleCert = keyStore.getCertificate(targetAlias);
if (singleCert != null) {
chain = new Certificate[]{singleCert};
} else {
throw new CertificateProviderException(
CcspErrorCode.CERTIFICATE_NOT_FOUND,
"Cadeia do certificado não encontrada no dispositivo PKCS#11."
);
}
}
logger.info("Executando assinatura digital no hardware PKCS#11 [alg={}]", alg.getJcaName());
Signature signature = (sunPkcs11Provider != null)
? Signature.getInstance(alg.getJcaName(), sunPkcs11Provider)
: Signature.getInstance(alg.getJcaName());
signature.initSign(privateKey);
signature.update(context.digest());
byte[] signatureBytes = signature.sign();
String certBase64 = Base64.getEncoder().encodeToString(chain[0].getEncoded());
List<String> chainBase64 = new ArrayList<>();
for (Certificate c : chain) {
chainBase64.add(Base64.getEncoder().encodeToString(c.getEncoded()));
}
return new SigningResult(
context.requestId(),
signatureBytes,
certBase64,
chainBase64,
alg.getJcaName(),
Instant.now()
);
} catch (CertificateProviderException cpe) {
throw cpe;
} catch (Exception e) {
logger.error("Erro na assinatura via dispositivo PKCS#11: {}", e.getMessage(), e);
throw new CertificateProviderException(
CcspErrorCode.PKCS11_ERROR,
"Erro ao assinar com o dispositivo PKCS#11: " + e.getMessage(),
e
);
}
}
private String resolveAlias(String hint) {
try {
Enumeration<String> aliases = keyStore.aliases();
List<String> list = Collections.list(aliases);
if (hint == null || hint.isBlank()) {
return list.isEmpty() ? null : list.get(0);
}
for (String alias : list) {
String certId = "p11-" + Integer.toHexString(alias.hashCode());
if (alias.equalsIgnoreCase(hint) || certId.equalsIgnoreCase(hint)) {
return alias;
}
}
} catch (Exception ignored) {
}
return null;
}
}
@@ -0,0 +1,318 @@
package br.com.cybercore.signer.core.provider;
import br.com.cybercore.signer.core.crypto.DigestAlgorithm;
import br.com.cybercore.signer.core.crypto.X509CertificateUtils;
import br.com.cybercore.signer.core.domain.CertificateType;
import br.com.cybercore.signer.core.domain.ManagedCertificate;
import br.com.cybercore.signer.core.domain.SigningContext;
import br.com.cybercore.signer.core.domain.SigningResult;
import br.com.cybercore.signer.core.protocol.CcspErrorCode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.PrivateKey;
import java.security.Signature;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Collections;
import java.util.Enumeration;
import java.util.List;
import java.util.Objects;
/**
* Provedor de certificados A1 em formato PKCS#12 (.pfx / .p12).
* Mantém chaves e senhas isoladas na memória local do agente sem persistência insegura ou vazamento em logs.
*/
public class Pkcs12CertificateProvider implements CertificateProvider {
private static final Logger logger = LoggerFactory.getLogger(Pkcs12CertificateProvider.class);
private static final String PROVIDER_ID = "provider-a1";
private final String providerId;
private final String displayName;
private KeyStore keyStore;
private char[] currentPassword;
public Pkcs12CertificateProvider() {
this(PROVIDER_ID, "Certificados A1 (PKCS#12)");
}
public Pkcs12CertificateProvider(String providerId, String displayName) {
this.providerId = Objects.requireNonNull(providerId);
this.displayName = Objects.requireNonNull(displayName);
}
/**
* Carrega um KeyStore PKCS#12 diretamente a partir de bytes.
* A senha é utilizada exclusivamente para decifrar a chave em memória e nunca é gravada em logs.
*/
public synchronized void loadFromBytes(byte[] pfxData, char[] password) throws CertificateProviderException {
Objects.requireNonNull(pfxData, "Dados do certificado não podem ser nulos.");
try (ByteArrayInputStream bais = new ByteArrayInputStream(pfxData)) {
loadFromStream(bais, password);
} catch (CertificateProviderException cpe) {
throw cpe;
} catch (Exception e) {
throw new CertificateProviderException(
CcspErrorCode.CERTIFICATE_INVALID,
"Falha ao processar arquivo PKCS#12: " + e.getMessage(),
e
);
}
}
/**
* Carrega um KeyStore PKCS#12 a partir de um InputStream.
*/
public synchronized void loadFromStream(InputStream stream, char[] password) throws CertificateProviderException {
try {
KeyStore ks = KeyStore.getInstance("PKCS12");
ks.load(stream, password);
this.keyStore = ks;
this.currentPassword = password != null ? password.clone() : new char[0];
logger.info("KeyStore PKCS#12 carregado com sucesso no provedor '{}'", providerId);
} catch (Exception e) {
String msg = e.getMessage() != null ? e.getMessage().toLowerCase() : "";
if (msg.contains("password") || msg.contains("keystore password was incorrect") || msg.contains("mac")) {
throw new CertificateProviderException(
CcspErrorCode.PIN_INCORRECT,
"Senha do certificado A1 incorreta.",
e
);
}
throw new CertificateProviderException(
CcspErrorCode.CERTIFICATE_INVALID,
"Não foi possível carregar o arquivo PKCS#12: " + e.getMessage(),
e
);
}
}
/**
* Define diretamente uma instância de KeyStore PKCS#12 pré-carregada (útil em testes ou integrações).
*/
public synchronized void setKeyStore(KeyStore keyStore, char[] password) {
this.keyStore = keyStore;
this.currentPassword = password != null ? password.clone() : new char[0];
}
public synchronized void clear() {
this.keyStore = null;
if (this.currentPassword != null) {
java.util.Arrays.fill(this.currentPassword, ' ');
this.currentPassword = null;
}
}
@Override
public String getProviderId() {
return providerId;
}
@Override
public String getDisplayName() {
return displayName;
}
@Override
public ProviderType getType() {
return ProviderType.A1_FILE;
}
@Override
public synchronized boolean isAvailable() {
return keyStore != null;
}
@Override
public synchronized List<ManagedCertificate> listCertificates() throws CertificateProviderException {
if (!isAvailable()) {
return Collections.emptyList();
}
try {
List<ManagedCertificate> certificates = new ArrayList<>();
Enumeration<String> aliases = keyStore.aliases();
while (aliases.hasMoreElements()) {
String alias = aliases.nextElement();
boolean isKeyEntry = keyStore.isKeyEntry(alias);
Certificate cert = keyStore.getCertificate(alias);
if (!(cert instanceof X509Certificate x509)) {
continue;
}
String certId = "a1-" + Integer.toHexString(alias.hashCode());
ManagedCertificate mc = X509CertificateUtils.toManagedCertificate(
certId,
alias,
x509,
CertificateType.A1,
isKeyEntry,
providerId,
"Arquivo PKCS#12 (.pfx/.p12)"
);
certificates.add(mc);
}
return Collections.unmodifiableList(certificates);
} catch (KeyStoreException e) {
throw new CertificateProviderException(
CcspErrorCode.PROVIDER_NOT_AVAILABLE,
"Erro ao consultar certificados do KeyStore PKCS#12: " + e.getMessage(),
e
);
}
}
@Override
public synchronized SigningResult sign(SigningContext context) throws CertificateProviderException {
Objects.requireNonNull(context, "Contexto de assinatura não pode ser nulo.");
if (!isAvailable()) {
throw new CertificateProviderException(
CcspErrorCode.PROVIDER_NOT_AVAILABLE,
"Nenhum certificado A1 carregado neste provedor."
);
}
DigestAlgorithm alg = DigestAlgorithm.fromName(context.algorithm());
alg.validateDigestLength(context.digest());
String targetAlias = resolveAlias(context.certificateHint());
if (targetAlias == null) {
throw new CertificateProviderException(
CcspErrorCode.CERTIFICATE_NOT_FOUND,
"Certificado A1 não encontrado para a requisição: " + context.certificateHint()
);
}
try {
PrivateKey privateKey = (PrivateKey) keyStore.getKey(targetAlias, currentPassword);
if (privateKey == null) {
throw new CertificateProviderException(
CcspErrorCode.PRIVATE_KEY_NOT_AVAILABLE,
"Chave privada não encontrada para o certificado: " + targetAlias
);
}
Certificate[] chain = keyStore.getCertificateChain(targetAlias);
if (chain == null || chain.length == 0) {
Certificate singleCert = keyStore.getCertificate(targetAlias);
if (singleCert != null) {
chain = new Certificate[]{singleCert};
} else {
throw new CertificateProviderException(
CcspErrorCode.CERTIFICATE_NOT_FOUND,
"Cadeia de certificados ausente para o alias: " + targetAlias
);
}
}
logger.info("Executando assinatura com certificado A1 [alg={}]", alg.getJcaName());
Signature signature = Signature.getInstance(alg.getJcaName());
signature.initSign(privateKey);
signature.update(context.digest());
byte[] signatureBytes = signature.sign();
String certBase64 = Base64.getEncoder().encodeToString(chain[0].getEncoded());
List<String> chainBase64 = new ArrayList<>();
for (Certificate c : chain) {
chainBase64.add(Base64.getEncoder().encodeToString(c.getEncoded()));
}
return new SigningResult(
context.requestId(),
signatureBytes,
certBase64,
chainBase64,
alg.getJcaName(),
Instant.now()
);
} catch (CertificateProviderException cpe) {
throw cpe;
} catch (Exception e) {
logger.error("Erro ao realizar assinatura com certificado A1: {}", e.getMessage(), e);
throw new CertificateProviderException(
CcspErrorCode.SIGNATURE_FAILED,
"Erro ao assinar com certificado A1: " + e.getMessage(),
e
);
}
}
@Override
public synchronized java.util.Optional<br.com.cybercore.signer.core.domain.SigningKeyMaterial> getKeyMaterial(String certificateId, char[] pinOrPassword) throws CertificateProviderException {
if (!isAvailable()) {
return java.util.Optional.empty();
}
String targetAlias = resolveAlias(certificateId);
if (targetAlias == null) {
return java.util.Optional.empty();
}
char[] passToUse = (currentPassword != null && currentPassword.length > 0) ? currentPassword : pinOrPassword;
try {
PrivateKey privateKey = (PrivateKey) keyStore.getKey(targetAlias, passToUse);
if (privateKey == null) {
return java.util.Optional.empty();
}
Certificate[] chain = keyStore.getCertificateChain(targetAlias);
if (chain == null || chain.length == 0) {
Certificate single = keyStore.getCertificate(targetAlias);
if (single != null) {
chain = new Certificate[]{single};
}
}
if (chain == null || chain.length == 0) {
return java.util.Optional.empty();
}
X509Certificate[] x509Chain = new X509Certificate[chain.length];
for (int i = 0; i < chain.length; i++) {
x509Chain[i] = (X509Certificate) chain[i];
}
return java.util.Optional.of(new br.com.cybercore.signer.core.domain.SigningKeyMaterial(privateKey, x509Chain, null));
} catch (Exception e) {
throw new CertificateProviderException(
CcspErrorCode.SIGNATURE_FAILED,
"Falha ao obter chave privada e certificados A1: " + e.getMessage(),
e
);
}
}
private String resolveAlias(String hint) {
try {
Enumeration<String> aliases = keyStore.aliases();
List<String> list = Collections.list(aliases);
if (hint == null || hint.isBlank()) {
return list.isEmpty() ? null : list.get(0);
}
for (String alias : list) {
String certId = "a1-" + Integer.toHexString(alias.hashCode());
if (alias.equalsIgnoreCase(hint) || certId.equalsIgnoreCase(hint)) {
return alias;
}
Certificate c = keyStore.getCertificate(alias);
if (c instanceof X509Certificate x509) {
String sha1 = X509CertificateUtils.calculateFingerprint(x509, "SHA-1");
String sha256 = X509CertificateUtils.calculateFingerprint(x509, "SHA-256");
if (sha1.equalsIgnoreCase(hint) || sha256.equalsIgnoreCase(hint)) {
return alias;
}
}
}
} catch (Exception ignored) {
}
return null;
}
}
@@ -0,0 +1,21 @@
package br.com.cybercore.signer.core.provider;
/**
* Classificação da tecnologia ou repositório do provedor de certificados.
*/
public enum ProviderType {
A1_FILE("Arquivo PKCS#12 (.pfx/.p12)"),
A3_WINDOWS_STORE("Windows Certificate Store (CAPI/CNG)"),
A3_PKCS11("Hardware PKCS#11 (Smart Card / Token USB)"),
MOCK("Simulador de Desenvolvimento");
private final String description;
ProviderType(String description) {
this.description = description;
}
public String getDescription() {
return description;
}
}
@@ -0,0 +1,307 @@
package br.com.cybercore.signer.core.provider;
import br.com.cybercore.signer.core.crypto.DigestAlgorithm;
import br.com.cybercore.signer.core.crypto.X509CertificateUtils;
import br.com.cybercore.signer.core.domain.CertificateStatus;
import br.com.cybercore.signer.core.domain.CertificateType;
import br.com.cybercore.signer.core.domain.ManagedCertificate;
import br.com.cybercore.signer.core.domain.SigningContext;
import br.com.cybercore.signer.core.domain.SigningResult;
import br.com.cybercore.signer.core.protocol.CcspErrorCode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.security.KeyStore;
import java.security.PrivateKey;
import java.security.Signature;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Collections;
import java.util.Enumeration;
import java.util.List;
import java.util.Objects;
/**
* Provedor de certificados integrado nativamente ao repositório de certificados do Windows (Windows-MY).
* Utiliza o subsistema SunMSCAPI/CAPI/CNG para gerenciar chaves privadas e certificados A1/A3 sem expor credenciais.
*/
public class WindowsCertificateStoreProvider implements CertificateProvider {
private static final Logger logger = LoggerFactory.getLogger(WindowsCertificateStoreProvider.class);
private static final String PROVIDER_ID = "provider-win-store";
private static final String KEYSTORE_TYPE = "Windows-MY";
private KeyStore keyStore;
private final boolean isWindows;
public WindowsCertificateStoreProvider() {
String osName = System.getProperty("os.name", "").toLowerCase();
this.isWindows = osName.contains("win");
if (this.isWindows) {
initKeyStore();
}
}
private synchronized void initKeyStore() {
try {
this.keyStore = KeyStore.getInstance(KEYSTORE_TYPE);
this.keyStore.load(null, null);
logger.info("Windows Certificate Store (Windows-MY) inicializado com sucesso.");
} catch (Throwable t) {
logger.warn("Não foi possível carregar o repositório Windows-MY: {}", t.getMessage());
this.keyStore = null;
}
}
@Override
public String getProviderId() {
return PROVIDER_ID;
}
@Override
public String getDisplayName() {
return "Windows Certificate Store (Windows-MY)";
}
@Override
public ProviderType getType() {
return ProviderType.A3_WINDOWS_STORE;
}
@Override
public boolean isAvailable() {
return isWindows && keyStore != null;
}
@Override
public synchronized List<ManagedCertificate> listCertificates() throws CertificateProviderException {
if (!isAvailable()) {
return Collections.emptyList();
}
try {
// Recarrega o repositório para detectar tokens recém-inseridos
this.keyStore.load(null, null);
List<ManagedCertificate> certificates = new ArrayList<>();
Enumeration<String> aliases = keyStore.aliases();
while (aliases.hasMoreElements()) {
String alias = aliases.nextElement();
try {
if (!keyStore.isKeyEntry(alias)) {
continue;
}
Certificate cert = keyStore.getCertificate(alias);
if (!(cert instanceof X509Certificate x509)) {
continue;
}
// Heurística de tipo A1 vs A3:
// ICP-Brasil A3 tem tipicamente 3 anos (validade > ~400 dias).
// A1 tem validade de até 1 ano (<= 370 dias).
long validityDays = ChronoUnit.DAYS.between(x509.getNotBefore().toInstant(), x509.getNotAfter().toInstant());
CertificateType certType = validityDays > 400 ? CertificateType.A3 : CertificateType.A1;
String deviceName = certType == CertificateType.A3
? "Token / Smart Card (Windows CAPI/CNG)"
: "Repositório do Windows (Software A1)";
String certId = "win-" + Integer.toHexString(alias.hashCode());
ManagedCertificate managedCert = X509CertificateUtils.toManagedCertificate(
certId,
alias,
x509,
certType,
true,
PROVIDER_ID,
deviceName
);
certificates.add(managedCert);
} catch (Exception e) {
logger.warn("Ignorando alias '{}' devido a erro na leitura: {}", alias, e.getMessage());
}
}
return Collections.unmodifiableList(certificates);
} catch (Exception e) {
throw new CertificateProviderException(
CcspErrorCode.PROVIDER_NOT_AVAILABLE,
"Falha ao enumerar certificados do Windows Certificate Store: " + e.getMessage(),
e
);
}
}
@Override
public synchronized SigningResult sign(SigningContext context) throws CertificateProviderException {
Objects.requireNonNull(context, "Contexto de assinatura não pode ser nulo.");
if (!isAvailable()) {
throw new CertificateProviderException(
CcspErrorCode.PROVIDER_NOT_AVAILABLE,
"Repositório Windows Certificate Store não está disponível neste sistema operacional."
);
}
// Valida algoritmo e tamanho de digest
DigestAlgorithm alg = DigestAlgorithm.fromName(context.algorithm());
alg.validateDigestLength(context.digest());
String targetAlias = resolveAlias(context.certificateHint());
if (targetAlias == null) {
throw new CertificateProviderException(
CcspErrorCode.CERTIFICATE_NOT_FOUND,
"Nenhum certificado correspondente encontrado no Windows Certificate Store para o hint: " + context.certificateHint()
);
}
try {
PrivateKey privateKey = (PrivateKey) keyStore.getKey(targetAlias, null);
if (privateKey == null) {
throw new CertificateProviderException(
CcspErrorCode.PRIVATE_KEY_NOT_AVAILABLE,
"Chave privada não acessível para o certificado: " + targetAlias
);
}
Certificate[] chain = keyStore.getCertificateChain(targetAlias);
if (chain == null || chain.length == 0) {
Certificate singleCert = keyStore.getCertificate(targetAlias);
if (singleCert != null) {
chain = new Certificate[]{singleCert};
} else {
throw new CertificateProviderException(
CcspErrorCode.CERTIFICATE_NOT_FOUND,
"Cadeia de certificação não encontrada para o alias: " + targetAlias
);
}
}
logger.info("Executando assinatura digital com algoritmo {} via Windows-MY para alias '{}'",
alg.getJcaName(), targetAlias);
Signature signature = Signature.getInstance(alg.getJcaName());
signature.initSign(privateKey);
signature.update(context.digest());
byte[] signatureBytes = signature.sign();
String certBase64 = Base64.getEncoder().encodeToString(chain[0].getEncoded());
List<String> chainBase64 = new ArrayList<>();
for (Certificate c : chain) {
chainBase64.add(Base64.getEncoder().encodeToString(c.getEncoded()));
}
return new SigningResult(
context.requestId(),
signatureBytes,
certBase64,
chainBase64,
alg.getJcaName(),
Instant.now()
);
} catch (CertificateProviderException cpe) {
throw cpe;
} catch (Exception e) {
logger.error("Erro durante a assinatura via Windows-MY: {}", e.getMessage(), e);
throw new CertificateProviderException(
CcspErrorCode.SIGNATURE_FAILED,
"Erro ao assinar com o Windows Certificate Store: " + e.getMessage(),
e
);
} finally {
try {
// Força liberação de handles no SunMSCAPI para que o driver de SmartCard/Token
// possa reavaliar o estado de autenticação de PIN
if (keyStore != null) {
keyStore.load(null, null);
}
} catch (Exception ignored) {
}
// Se configurado para sempre exigir PIN (sem cache), forçamos o reset da sessão no Smart Card
// para que o Windows BaseCSP e o SafeSign invalidem o cache de processo e peçam o PIN na próxima assinatura
if (br.com.cybercore.signer.core.security.PinSessionManager.getInstance().isAlwaysPromptPin()) {
br.com.cybercore.signer.core.security.SmartCardSessionManager.resetCardSessions();
}
}
}
private String resolveAlias(String hint) {
if (hint == null || hint.isBlank()) {
return null;
}
try {
Enumeration<String> aliases = keyStore.aliases();
while (aliases.hasMoreElements()) {
String alias = aliases.nextElement();
String certId = "win-" + Integer.toHexString(alias.hashCode());
if (alias.equalsIgnoreCase(hint) || certId.equalsIgnoreCase(hint)) {
return alias;
}
Certificate c = keyStore.getCertificate(alias);
if (c instanceof X509Certificate x509) {
String sha1 = X509CertificateUtils.calculateFingerprint(x509, "SHA-1");
String sha256 = X509CertificateUtils.calculateFingerprint(x509, "SHA-256");
if (sha1.equalsIgnoreCase(hint) || sha256.equalsIgnoreCase(hint)) {
return alias;
}
}
}
} catch (Exception ignored) {
}
return null;
}
@Override
public synchronized java.util.Optional<br.com.cybercore.signer.core.domain.SigningKeyMaterial> getKeyMaterial(String certificateId, char[] pinOrPassword) throws CertificateProviderException {
if (!isAvailable()) {
return java.util.Optional.empty();
}
String targetAlias = resolveAlias(certificateId);
if (targetAlias == null) {
return java.util.Optional.empty();
}
try {
PrivateKey privateKey = (PrivateKey) keyStore.getKey(targetAlias, null);
if (privateKey == null) {
return java.util.Optional.empty();
}
Certificate[] chain = keyStore.getCertificateChain(targetAlias);
if (chain == null || chain.length == 0) {
Certificate single = keyStore.getCertificate(targetAlias);
if (single != null) {
chain = new Certificate[]{single};
}
}
if (chain == null || chain.length == 0) {
return java.util.Optional.empty();
}
X509Certificate[] x509Chain = new X509Certificate[chain.length];
for (int i = 0; i < chain.length; i++) {
x509Chain[i] = (X509Certificate) chain[i];
}
return java.util.Optional.of(new br.com.cybercore.signer.core.domain.SigningKeyMaterial(privateKey, x509Chain, keyStore.getProvider()));
} catch (Exception e) {
throw new CertificateProviderException(
CcspErrorCode.SIGNATURE_FAILED,
"Falha ao obter chave privada e certificados no Windows Store: " + e.getMessage(),
e
);
}
}
@Override
public synchronized void refresh() {
if (isWindows) {
initKeyStore();
}
}
}
@@ -0,0 +1,162 @@
package br.com.cybercore.signer.core.security;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
/**
* Registro de aplicações web autorizadas a se comunicar com o agente CyberCore Signer.
* Suporta persistência segura no diretório do usuário (~/.cybercore/authorized-apps.json).
*/
public final class ApplicationRegistry {
private static final Logger logger = LoggerFactory.getLogger(ApplicationRegistry.class);
private static final String DEFAULT_FILE_NAME = "authorized-apps.json";
private static final ApplicationRegistry INSTANCE = new ApplicationRegistry();
private final Path storagePath;
private final Map<String, AuthorizedApplication> applications = new ConcurrentHashMap<>();
private final ObjectMapper objectMapper;
public static ApplicationRegistry getInstance() {
return INSTANCE;
}
public ApplicationRegistry() {
this(resolveDefaultStoragePath());
}
public ApplicationRegistry(Path storagePath) {
this.storagePath = storagePath;
this.objectMapper = new ObjectMapper().registerModule(new JavaTimeModule());
load();
}
private static Path resolveDefaultStoragePath() {
String userHome = System.getProperty("user.home", ".");
return Path.of(userHome, ".cybercore", DEFAULT_FILE_NAME);
}
/**
* Busca uma aplicação pelo seu ID único.
*/
public Optional<AuthorizedApplication> findById(String applicationId) {
if (applicationId == null || applicationId.isBlank()) {
return Optional.empty();
}
return Optional.ofNullable(applications.get(applicationId));
}
/**
* Busca uma aplicação que contenha a origem especificada autorizada.
*/
public Optional<AuthorizedApplication> findByOrigin(String origin) {
if (origin == null || origin.isBlank()) {
return Optional.empty();
}
return applications.values().stream()
.filter(app -> app.isOriginAllowed(origin))
.findFirst();
}
/**
* Verifica se uma aplicação e sua respectiva origem estão autorizadas e ativas.
*/
public boolean isAuthorized(String applicationId, String origin) {
Optional<AuthorizedApplication> appOpt = findById(applicationId);
return appOpt.map(app -> app.isOriginAllowed(origin)).orElse(false);
}
/**
* Registra ou atualiza uma aplicação autorizada e persiste a alteração.
*/
public synchronized void register(AuthorizedApplication app) {
Objects.requireNonNull(app, "Aplicação não pode ser nula");
applications.put(app.applicationId(), app);
logger.info("Aplicação registrada: '{}' [id={}] com origens: {}", app.name(), app.applicationId(), app.allowedOrigins());
save();
}
/**
* Revoga a autorização de uma aplicação.
*/
public synchronized void revoke(String applicationId) {
if (applicationId != null && applications.remove(applicationId) != null) {
logger.info("Autorização revogada para a aplicação [id={}]", applicationId);
save();
}
}
/**
* Retorna a lista imutável de todas as aplicações cadastradas.
*/
public List<AuthorizedApplication> listAll() {
return Collections.unmodifiableList(new ArrayList<>(applications.values()));
}
/**
* Carrega as aplicações persistidas em disco.
*/
public synchronized void load() {
if (storagePath == null || !Files.exists(storagePath)) {
logger.debug("Nenhum arquivo de aplicações persistidas encontrado em {}", storagePath);
return;
}
try {
List<AuthorizedApplication> list = objectMapper.readValue(
storagePath.toFile(),
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);
} catch (Exception e) {
logger.warn("Falha ao ler aplicações autorizadas de {}: {}", storagePath, e.getMessage());
}
}
/**
* Salva as aplicações atuais em disco.
*/
public synchronized void save() {
if (storagePath == null) {
return;
}
try {
Path parent = storagePath.getParent();
if (parent != null && !Files.exists(parent)) {
Files.createDirectories(parent);
}
objectMapper.writerWithDefaultPrettyPrinter().writeValue(
storagePath.toFile(),
new ArrayList<>(applications.values())
);
logger.debug("Aplicações autorizadas salvas com sucesso em {}", storagePath);
} catch (Exception e) {
logger.error("Erro ao salvar aplicações autorizadas em {}: {}", storagePath, e.getMessage());
}
}
public synchronized void clear() {
applications.clear();
save();
}
}
@@ -0,0 +1,44 @@
package br.com.cybercore.signer.core.security;
import java.io.Serializable;
import java.time.Instant;
import java.util.List;
import java.util.Objects;
import java.util.Set;
/**
* Representa uma aplicação web autorizada a solicitar assinaturas ao CyberCore Signer.
*/
public record AuthorizedApplication(
String applicationId,
String name,
Set<String> allowedOrigins,
boolean enabled,
Instant registeredAt
) implements Serializable {
public AuthorizedApplication {
Objects.requireNonNull(applicationId, "applicationId must not be null");
Objects.requireNonNull(name, "name must not be null");
allowedOrigins = allowedOrigins != null ? Set.copyOf(allowedOrigins) : Set.of();
if (registeredAt == null) {
registeredAt = Instant.now();
}
}
/**
* Verifica se uma determinada Origin web está autorizada para esta aplicação.
*/
public boolean isOriginAllowed(String origin) {
if (!enabled || origin == null || origin.isBlank()) {
return false;
}
String normalized = origin.trim().toLowerCase();
for (String allowed : allowedOrigins) {
if (allowed.trim().toLowerCase().equals(normalized)) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,53 @@
package br.com.cybercore.signer.core.security;
import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.ConcurrentHashMap;
/**
* Gerenciador de nonces em memória para proteção contra ataques de replay (CCSP/1).
* Garante que uma requisição com o mesmo nonce e correlation_id não seja reprocessada.
*/
public class NonceManager {
private final Duration windowDuration;
private final ConcurrentHashMap<String, Instant> usedNonces = new ConcurrentHashMap<>();
public NonceManager(Duration windowDuration) {
this.windowDuration = windowDuration != null ? windowDuration : Duration.ofSeconds(120);
}
public NonceManager() {
this(Duration.ofSeconds(120));
}
/**
* Tenta registrar um nonce.
*
* @param nonce Nonce gerado pelo cliente/requisição.
* @return {@code true} se o nonce for novo e válido; {@code false} se já tiver sido utilizado (replay).
*/
public boolean registerNonce(String nonce) {
if (nonce == null || nonce.isBlank()) {
return false;
}
cleanupExpired();
Instant now = Instant.now();
Instant existing = usedNonces.putIfAbsent(nonce, now);
return existing == null;
}
/**
* Remove nonces expirados além da janela máxima de aceitação.
*/
public void cleanupExpired() {
Instant cutoff = Instant.now().minus(windowDuration);
usedNonces.entrySet().removeIf(entry -> entry.getValue().isBefore(cutoff));
}
public int activeNonceCount() {
return usedNonces.size();
}
}
@@ -0,0 +1,44 @@
package br.com.cybercore.signer.core.security;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.URI;
import java.util.Objects;
import java.util.Set;
/**
* Validador estrito de origens web conforme as especificações de segurança do protocolo CCSP.
*/
public class OriginValidator {
private static final Logger logger = LoggerFactory.getLogger(OriginValidator.class);
/**
* Valida se a origem recebida na requisição corresponde às origens autorizadas da aplicação.
*/
public boolean isAllowed(String requestOrigin, AuthorizedApplication application) {
if (application == null || !application.enabled()) {
logger.warn("Aplicação nula ou desabilitada durante validação de origem.");
return false;
}
if (requestOrigin == null || requestOrigin.isBlank()) {
logger.warn("Requisição sem cabeçalho Origin rejeitada para a aplicação {}", application.applicationId());
return false;
}
try {
URI uri = URI.create(requestOrigin.trim());
String normalizedOrigin = uri.getScheme() + "://" + uri.getAuthority();
boolean allowed = application.isOriginAllowed(normalizedOrigin);
if (!allowed) {
logger.warn("Origem '{}' rejeitada para a aplicação '{}'", normalizedOrigin, application.applicationId());
}
return allowed;
} catch (Exception e) {
logger.warn("Origem malformatada '{}': {}", requestOrigin, e.getMessage());
return false;
}
}
}
@@ -0,0 +1,106 @@
package br.com.cybercore.signer.core.security;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Arrays;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
/**
* Gerenciador de PIN e credenciais voláteis de sessão do CyberCore Signer.
* Armazena temporariamente em memória RAM (nunca em disco) o PIN de tokens A3
* exclusivamente quando o usuário autorizar explicitamente a opção "Salvar PIN na sessão".
* Todas as senhas e PINs são sobrescritos com espaços em branco (' ') no descarte.
*/
public class PinSessionManager {
private static final Logger logger = LoggerFactory.getLogger(PinSessionManager.class);
private static final PinSessionManager INSTANCE = new PinSessionManager();
private final Map<String, char[]> sessionPins = new ConcurrentHashMap<>();
private volatile boolean alwaysPromptPin = false;
public PinSessionManager() {
}
public static PinSessionManager getInstance() {
return INSTANCE;
}
/**
* Salva o PIN de um certificado/token para a sessão atual em memória volátil.
*/
public synchronized void savePin(String certId, char[] pin) {
Objects.requireNonNull(certId, "certId must not be null");
if (pin == null || pin.length == 0) {
clearPin(certId);
return;
}
// Limpa PIN anterior caso exista
clearPin(certId);
sessionPins.put(certId, pin.clone());
logger.debug("PIN salvo em memória de sessão para o certificado '{}'. Tamanho: {} dígitos", certId, pin.length);
}
/**
* Recupera o PIN salvo em sessão para o certificado informado.
*/
public synchronized char[] getPin(String certId) {
if (certId == null || alwaysPromptPin) {
return null;
}
char[] stored = sessionPins.get(certId);
return stored != null ? stored.clone() : null;
}
/**
* Verifica se existe um PIN válido salvo nesta sessão para o certificado.
*/
public synchronized boolean hasPin(String certId) {
if (certId == null || alwaysPromptPin) {
return false;
}
return sessionPins.containsKey(certId);
}
/**
* Remove o PIN salvo para um certificado específico, zerando a memória.
*/
public synchronized void clearPin(String certId) {
if (certId != null) {
char[] old = sessionPins.remove(certId);
if (old != null) {
Arrays.fill(old, ' ');
}
}
}
/**
* Limpa imediatamente todos os PINs em memória e zera seus caracteres.
*/
public synchronized void clearAll() {
for (char[] pin : sessionPins.values()) {
if (pin != null) {
Arrays.fill(pin, ' ');
}
}
sessionPins.clear();
logger.info("Todos os PINs em memória de sessão foram limpos e zerados com sucesso.");
}
public boolean isAlwaysPromptPin() {
return alwaysPromptPin;
}
public void setAlwaysPromptPin(boolean alwaysPromptPin) {
this.alwaysPromptPin = alwaysPromptPin;
if (alwaysPromptPin) {
clearAll();
}
}
}
@@ -0,0 +1,69 @@
package br.com.cybercore.signer.core.security;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.lang.reflect.Method;
import java.util.List;
/**
* Gerencia sessões de hardware Smart Card (Tokens USB / Cartões ICP-Brasil) no nível do subsistema PC/SC (winscard).
* Permite forçar o reset do canal do cartão para invalidar caches de PIN de processo (BaseCSP / Minidriver),
* garantindo que novas operações de assinatura exijam autenticação explícita quando configurado.
*/
public final class SmartCardSessionManager {
private static final Logger logger = LoggerFactory.getLogger(SmartCardSessionManager.class);
private SmartCardSessionManager() {
}
/**
* Reseta as conexões ativas com todos os terminais Smart Card conectados que possuam cartão presente.
* Envia o comando SCARD_RESET_CARD (warm reset via javax.smartcardio Card.disconnect(true)),
* invalidando o estado de autenticação de PIN nos drivers e no CryptoAPI/CNG do Windows.
*/
@SuppressWarnings("unchecked")
public static void resetCardSessions() {
try {
Class<?> tfClass = Class.forName("javax.smartcardio.TerminalFactory");
Method getDefaultMethod = tfClass.getMethod("getDefault");
Object factory = getDefaultMethod.invoke(null);
Method terminalsMethod = factory.getClass().getMethod("terminals");
Object cardTerminals = terminalsMethod.invoke(factory);
Class<?> stateEnumClass = Class.forName("javax.smartcardio.CardTerminals$State");
Object cardPresent = Enum.valueOf((Class<Enum>) stateEnumClass, "CARD_PRESENT");
Method listMethod = cardTerminals.getClass().getMethod("list", stateEnumClass);
List<?> terminals = (List<?>) listMethod.invoke(cardTerminals, cardPresent);
if (terminals == null || terminals.isEmpty()) {
logger.debug("Nenhum terminal Smart Card com cartão presente encontrado para reset de sessão.");
return;
}
for (Object terminal : terminals) {
try {
Method getNameMethod = terminal.getClass().getMethod("getName");
String termName = (String) getNameMethod.invoke(terminal);
Method connectMethod = terminal.getClass().getMethod("connect", String.class);
Object card = connectMethod.invoke(terminal, "*");
if (card != null) {
Method disconnectMethod = card.getClass().getMethod("disconnect", boolean.class);
// disconnect(true) envia SCARD_RESET_CARD ao driver do Windows / SafeSign
disconnectMethod.invoke(card, true);
logger.info("Sessão de Smart Card resetada com sucesso no terminal '{}' (cache de PIN invalidado)", termName);
}
} catch (Throwable cardError) {
logger.debug("Falha ao resetar terminal Smart Card individual: {}", cardError.getMessage());
}
}
} catch (Throwable t) {
logger.debug("SmartCardIO não disponível ou sem suporte no ambiente: {}", t.getMessage());
}
}
}
@@ -0,0 +1,197 @@
package br.com.cybercore.signer.core.security;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.lang.reflect.Method;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.CopyOnWriteArrayList;
/**
* Monitor em segundo plano para detecção em tempo real de inserção e remoção física
* de Smart Cards e Tokens USB ICP-Brasil via subsistema PC/SC (javax.smartcardio).
*
* Implementa os requisitos 5 e 13 do CyberCore Signer:
* - Detecta conexão e remoção de hardware;
* - Registra logs explícitos de auditoria para eventos de TOKEN_REMOVED;
* - Invalida automaticamente sessões e caches de PIN quando um dispositivo físico é removido;
* - Notifica observadores (ex: UI e CertificateManager) para atualização em tempo real.
*/
public class SmartCardWatcher {
private static final Logger logger = LoggerFactory.getLogger(SmartCardWatcher.class);
public interface CardEventListener {
void onCardInserted(String terminalName);
void onCardRemoved(String terminalName);
}
private final List<CardEventListener> listeners = new CopyOnWriteArrayList<>();
private final Set<String> knownCards = Collections.synchronizedSet(new HashSet<>());
private volatile boolean running = false;
private Thread watcherThread;
public SmartCardWatcher() {
}
public void addListener(CardEventListener listener) {
if (listener != null) {
listeners.add(listener);
}
}
public void removeListener(CardEventListener listener) {
listeners.remove(listener);
}
/**
* Inicia o thread de monitoramento contínuo em segundo plano.
*/
public synchronized void start() {
if (running) {
return;
}
running = true;
watcherThread = new Thread(this::pollLoop, "smartcard-hardware-watcher");
watcherThread.setDaemon(true);
watcherThread.start();
logger.info("Monitoramento dinâmico de hardware Smart Card / Token USB iniciado.");
}
/**
* Finaliza o monitoramento.
*/
public synchronized void stop() {
running = false;
if (watcherThread != null) {
watcherThread.interrupt();
watcherThread = null;
}
logger.info("Monitoramento dinâmico de hardware Smart Card / Token USB finalizado.");
}
public boolean isRunning() {
return running;
}
@SuppressWarnings({"unchecked", "rawtypes"})
private void pollLoop() {
Object cardTerminals = null;
Method waitForChangeMethod = null;
Method listMethod = null;
Object cardPresentState = null;
try {
Class<?> tfClass = Class.forName("javax.smartcardio.TerminalFactory");
Method getDefaultMethod = tfClass.getMethod("getDefault");
Object factory = getDefaultMethod.invoke(null);
Method terminalsMethod = factory.getClass().getMethod("terminals");
cardTerminals = terminalsMethod.invoke(factory);
Class<?> stateEnumClass = Class.forName("javax.smartcardio.CardTerminals$State");
cardPresentState = Enum.valueOf((Class<Enum>) stateEnumClass, "CARD_PRESENT");
listMethod = cardTerminals.getClass().getMethod("list", stateEnumClass);
waitForChangeMethod = cardTerminals.getClass().getMethod("waitForChange", long.class);
// Popula estado inicial
Set<String> initial = getCurrentTerminalsWithCards(cardTerminals, listMethod, cardPresentState);
knownCards.addAll(initial);
} catch (Throwable t) {
logger.debug("Subsistema PC/SC (javax.smartcardio) indisponível ou sem suporte neste ambiente: {}", t.getMessage());
return;
}
while (running) {
try {
// Aguarda alteração de estado no hardware (timeout de 1.5s para checar flag running)
Boolean changed = (Boolean) waitForChangeMethod.invoke(cardTerminals, 1500L);
if (Boolean.TRUE.equals(changed) || !running) {
if (!running) break;
Set<String> current = getCurrentTerminalsWithCards(cardTerminals, listMethod, cardPresentState);
// Dispositivos removidos
Set<String> removed = new HashSet<>(knownCards);
removed.removeAll(current);
for (String term : removed) {
logger.warn("AUDITORIA [TOKEN_REMOVED]: Smart Card / Token USB desconectado do terminal '{}'", term);
// Ao remover o token, zera imediatamente PINs salvos em memória por segurança
PinSessionManager.getInstance().clearAll();
SmartCardSessionManager.resetCardSessions();
notifyRemoved(term);
}
// Dispositivos inseridos
Set<String> inserted = new HashSet<>(current);
inserted.removeAll(knownCards);
for (String term : inserted) {
logger.info("AUDITORIA: Smart Card / Token USB detectado no terminal '{}'", term);
notifyInserted(term);
}
knownCards.clear();
knownCards.addAll(current);
}
} catch (Throwable e) {
if (e instanceof InterruptedException || (e.getCause() instanceof InterruptedException)) {
Thread.currentThread().interrupt();
break;
}
if (!running) break;
// Espera curta para evitar consumo excessivo de CPU em caso de falha transitória
try {
Thread.sleep(2000);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
break;
}
}
}
}
@SuppressWarnings("unchecked")
private Set<String> getCurrentTerminalsWithCards(Object cardTerminals, Method listMethod, Object state) {
Set<String> result = new HashSet<>();
try {
List<?> terminals = (List<?>) listMethod.invoke(cardTerminals, state);
if (terminals != null) {
for (Object t : terminals) {
Method getName = t.getClass().getMethod("getName");
String name = (String) getName.invoke(t);
result.add(name);
}
}
} catch (Throwable ignored) {
}
return result;
}
private void notifyInserted(String terminal) {
for (CardEventListener listener : listeners) {
try {
listener.onCardInserted(terminal);
} catch (Throwable t) {
logger.error("Erro no listener onCardInserted: {}", t.getMessage());
}
}
}
private void notifyRemoved(String terminal) {
for (CardEventListener listener : listeners) {
try {
listener.onCardRemoved(terminal);
} catch (Throwable t) {
logger.error("Erro no listener onCardRemoved: {}", t.getMessage());
}
}
}
}
@@ -0,0 +1,417 @@
package br.com.cybercore.signer.core.validation;
import br.com.cybercore.signer.core.crypto.X509CertificateUtils;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.PDSignature;
import org.bouncycastle.cert.X509CertificateHolder;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.cms.CMSProcessableByteArray;
import org.bouncycastle.cms.CMSSignedData;
import org.bouncycastle.cms.SignerInformation;
import org.bouncycastle.cms.SignerInformationStore;
import org.bouncycastle.cms.jcajce.JcaSimpleSignerInfoVerifierBuilder;
import org.bouncycastle.util.Store;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.w3c.dom.Document;
import org.w3c.dom.NodeList;
import javax.xml.crypto.dsig.XMLSignature;
import javax.xml.crypto.dsig.XMLSignatureFactory;
import javax.xml.crypto.dsig.dom.DOMValidateContext;
import javax.xml.parsers.DocumentBuilderFactory;
import java.io.ByteArrayInputStream;
import java.security.cert.X509Certificate;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Date;
import java.util.List;
import java.util.Objects;
/**
* Serviço especializado para Validação Criptográfica de Assinaturas Digitais.
* Suporta Assinaturas Destacadas (Detached CMS/PKCS#7) e Assinaturas Incorporadas
* (PAdES em PDF, CMS/P7M com conteúdo encapsulado e XMLDSig).
*/
public final class SignatureValidationService {
private static final Logger logger = LoggerFactory.getLogger(SignatureValidationService.class);
private static final JcaX509CertificateConverter CERT_CONVERTER = new JcaX509CertificateConverter();
/**
* Valida uma Assinatura Destacada (Detached).
* Requer os bytes do documento original e os bytes do arquivo de assinatura (.p7s / .sig).
*/
public ValidationResult validateDetached(byte[] originalContent, byte[] signatureBytes, String docName) {
if (originalContent == null || originalContent.length == 0) {
return new ValidationResult(
ValidationStatus.ERROR,
ValidationType.DETACHED_CMS,
"Documento original não informado ou vazio.",
List.of(),
null,
docName
);
}
if (signatureBytes == null || signatureBytes.length == 0) {
return new ValidationResult(
ValidationStatus.ERROR,
ValidationType.DETACHED_CMS,
"Arquivo de assinatura (.p7s) não informado ou vazio.",
List.of(),
null,
docName
);
}
try {
CMSSignedData signedData = new CMSSignedData(new CMSProcessableByteArray(originalContent), signatureBytes);
return processCmsSignatures(signedData, ValidationType.DETACHED_CMS, null, docName);
} catch (Exception e) {
logger.warn("Erro ao processar assinatura destacada: {}", e.getMessage());
return new ValidationResult(
ValidationStatus.INVALID,
ValidationType.DETACHED_CMS,
"Falha ao decodificar pacote CMS/PKCS#7: " + e.getMessage(),
List.of(),
null,
docName
);
}
}
/**
* Valida uma Assinatura Incorporada / Anexada (Attached / Enveloped / Embedded).
* O arquivo assinado contém os dados e a assinatura juntos (PDF, P7M/CMS com dados, ou XML).
*/
public ValidationResult validateAttached(byte[] signedFileBytes, String fileName) {
if (signedFileBytes == null || signedFileBytes.length == 0) {
return new ValidationResult(
ValidationStatus.ERROR,
ValidationType.UNKNOWN,
"Arquivo assinado não informado ou vazio.",
List.of(),
null,
fileName
);
}
String nameLower = (fileName != null) ? fileName.toLowerCase() : "";
// 1. Verificação se é documento PDF
if (nameLower.endsWith(".pdf") || isPdfBytes(signedFileBytes)) {
return validatePdf(signedFileBytes, fileName);
}
// 2. Verificação se é documento XML
if (nameLower.endsWith(".xml") || isXmlBytes(signedFileBytes)) {
return validateXml(signedFileBytes, fileName);
}
// 3. Verificação se é CMS incorporado (P7M / P7S com dados embutidos)
try {
CMSSignedData signedData = new CMSSignedData(signedFileBytes);
if (signedData.getSignedContent() != null) {
Object contentObj = signedData.getSignedContent().getContent();
byte[] extracted = null;
if (contentObj instanceof byte[] b) {
extracted = b;
}
return processCmsSignatures(signedData, ValidationType.ATTACHED_CMS, extracted, fileName);
} else {
return new ValidationResult(
ValidationStatus.WARNING,
ValidationType.DETACHED_CMS,
"O arquivo de assinatura selecionado é do tipo Destacado (Detached) e não contém o documento embutido. Selecione o Documento Original para validar.",
List.of(),
null,
fileName
);
}
} catch (Exception e) {
logger.warn("Não foi possível decodificar como CMS: {}", e.getMessage());
}
return new ValidationResult(
ValidationStatus.ERROR,
ValidationType.UNKNOWN,
"Formato de arquivo assinado não reconhecido (esperado .pdf, .p7m, .p7s ou .xml).",
List.of(),
null,
fileName
);
}
/**
* Validação de assinaturas embutidas em documentos PDF (PAdES).
*/
private ValidationResult validatePdf(byte[] pdfBytes, String fileName) {
try (PDDocument document = Loader.loadPDF(pdfBytes)) {
List<PDSignature> signatures = document.getSignatureDictionaries();
if (signatures == null || signatures.isEmpty()) {
return new ValidationResult(
ValidationStatus.INVALID,
ValidationType.PADES_PDF,
"Nenhuma assinatura digital encontrada no documento PDF.",
List.of(),
null,
fileName
);
}
List<ValidationSignerInfo> signerInfos = new ArrayList<>();
boolean allValid = true;
for (PDSignature sig : signatures) {
byte[] contents = sig.getContents(pdfBytes);
byte[] signedByteRange = sig.getSignedContent(pdfBytes);
if (contents == null || signedByteRange == null) {
continue;
}
CMSSignedData cms = new CMSSignedData(new CMSProcessableByteArray(signedByteRange), contents);
ValidationResult cmsRes = processCmsSignatures(cms, ValidationType.PADES_PDF, null, fileName);
if (!cmsRes.isValid()) {
allValid = false;
}
for (ValidationSignerInfo info : cmsRes.signers()) {
String dateStr = (sig.getSignDate() != null)
? new SimpleDateFormat("dd/MM/yyyy HH:mm:ss").format(sig.getSignDate().getTime())
: info.signingTime();
signerInfos.add(new ValidationSignerInfo(
(sig.getName() != null && !sig.getName().isBlank()) ? sig.getName() : info.signerName(),
info.identifier(),
info.issuer(),
dateStr,
info.digestValid(),
info.signatureValid(),
info.algorithm(),
info.certificateSubject(),
(sig.getReason() != null ? "Motivo: " + sig.getReason() : "Assinatura PAdES embutida no PDF")
));
}
}
ValidationStatus status = allValid ? ValidationStatus.VALID : ValidationStatus.INVALID;
String msg = allValid
? String.format("PDF assinado e íntegro com %d assinatura(s) digital(is) válida(s).", signerInfos.size())
: "Assinatura(s) do PDF contêm falha de integridade ou autenticidade.";
return new ValidationResult(status, ValidationType.PADES_PDF, msg, signerInfos, null, fileName);
} catch (Exception e) {
logger.error("Falha ao validar documento PDF: {}", e.getMessage(), e);
return new ValidationResult(
ValidationStatus.ERROR,
ValidationType.PADES_PDF,
"Erro ao analisar documento PDF: " + e.getMessage(),
List.of(),
null,
fileName
);
}
}
/**
* Validação de assinaturas embutidas em documentos XML (XMLDSig).
*/
private ValidationResult validateXml(byte[] xmlBytes, String fileName) {
try {
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
dbf.setNamespaceAware(true);
Document doc = dbf.newDocumentBuilder().parse(new ByteArrayInputStream(xmlBytes));
NodeList nl = doc.getElementsByTagNameNS(XMLSignature.XMLNS, "Signature");
if (nl.getLength() == 0) {
return new ValidationResult(
ValidationStatus.INVALID,
ValidationType.XML_DSIG,
"Nenhum elemento <Signature> XMLDSig encontrado no documento.",
List.of(),
null,
fileName
);
}
XMLSignatureFactory fac = XMLSignatureFactory.getInstance("DOM");
List<ValidationSignerInfo> signers = new ArrayList<>();
boolean allValid = true;
for (int i = 0; i < nl.getLength(); i++) {
DOMValidateContext valContext = new DOMValidateContext(new KeyValueKeySelector(), nl.item(i));
XMLSignature signature = fac.unmarshalXMLSignature(valContext);
boolean coreValidity = signature.validate(valContext);
if (!coreValidity) {
allValid = false;
}
signers.add(new ValidationSignerInfo(
"Signatário XML #" + (i + 1),
"",
"Assinatura Enveloped XML",
new SimpleDateFormat("dd/MM/yyyy HH:mm:ss").format(new Date()),
coreValidity,
coreValidity,
signature.getSignedInfo().getSignatureMethod().getAlgorithm(),
"XMLDSig Element",
"Validade estrutural e criptográfica: " + (coreValidity ? "OK" : "FALHA")
));
}
ValidationStatus status = allValid ? ValidationStatus.VALID : ValidationStatus.INVALID;
String msg = allValid ? "Documento XML assinado e válido conforme padrão W3C XML-DSig." : "Assinatura XML inválida ou com integridade violada.";
return new ValidationResult(status, ValidationType.XML_DSIG, msg, signers, null, fileName);
} catch (Exception e) {
logger.error("Erro ao validar XML: {}", e.getMessage(), e);
return new ValidationResult(
ValidationStatus.ERROR,
ValidationType.XML_DSIG,
"Erro ao processar assinatura XML: " + e.getMessage(),
List.of(),
null,
fileName
);
}
}
/**
* Processa e valida criptograficamente os signatários de um pacote CMSSignedData.
*/
@SuppressWarnings("unchecked")
private ValidationResult processCmsSignatures(CMSSignedData signedData, ValidationType type,
byte[] extractedContent, String docName) {
List<ValidationSignerInfo> signers = new ArrayList<>();
boolean allValid = true;
Store<X509CertificateHolder> certStore = signedData.getCertificates();
SignerInformationStore signerStore = signedData.getSignerInfos();
for (SignerInformation signer : signerStore.getSigners()) {
boolean sigValid = false;
String signerName = "Signatário Criptográfico";
String identifier = "";
String issuer = "Desconhecido";
String subject = "";
String signingTime = new SimpleDateFormat("dd/MM/yyyy HH:mm:ss").format(new Date());
try {
Collection<X509CertificateHolder> matches = certStore.getMatches(signer.getSID());
if (!matches.isEmpty()) {
X509CertificateHolder certHolder = matches.iterator().next();
X509Certificate cert = CERT_CONVERTER.getCertificate(certHolder);
subject = cert.getSubjectX500Principal().getName();
issuer = cert.getIssuerX500Principal().getName();
signerName = X509CertificateUtils.extractCommonName(subject);
String cpf = X509CertificateUtils.extractCpf(cert, signerName);
if (cpf != null) {
identifier = "CPF: " + cpf;
} else {
String cnpj = X509CertificateUtils.extractCnpj(cert, signerName);
if (cnpj != null) {
identifier = "CNPJ: " + cnpj;
}
}
// Validação Criptográfica real com a chave pública do certificado
sigValid = signer.verify(new JcaSimpleSignerInfoVerifierBuilder().build(cert));
}
} catch (Exception e) {
logger.warn("Falha ao verificar assinatura de {}: {}", signerName, e.getMessage());
sigValid = false;
}
if (!sigValid) {
allValid = false;
}
signers.add(new ValidationSignerInfo(
signerName,
identifier,
issuer,
signingTime,
sigValid,
sigValid,
signer.getDigestAlgOID(),
subject,
sigValid ? "Integridade e assinatura conferidas com sucesso." : "Falha na validação criptográfica do resumo ou chave."
));
}
if (signers.isEmpty()) {
return new ValidationResult(
ValidationStatus.INVALID,
type,
"Nenhum signatário encontrado no arquivo de assinatura.",
List.of(),
extractedContent,
docName
);
}
ValidationStatus status = allValid ? ValidationStatus.VALID : ValidationStatus.INVALID;
String summary = allValid
? "Assinatura criptográfica válida e integridade do documento confirmada."
: "Uma ou mais assinaturas criptográficas falharam na validação.";
return new ValidationResult(status, type, summary, signers, extractedContent, docName);
}
private static boolean isPdfBytes(byte[] bytes) {
if (bytes.length < 5) return false;
return bytes[0] == '%' && bytes[1] == 'P' && bytes[2] == 'D' && bytes[3] == 'F';
}
private static boolean isXmlBytes(byte[] bytes) {
if (bytes.length < 5) return false;
// Pula possíveis BOM
int offset = 0;
if (bytes.length > 3 && (bytes[0] == (byte) 0xEF && bytes[1] == (byte) 0xBB && bytes[2] == (byte) 0xBF)) {
offset = 3;
}
while (offset < bytes.length && Character.isWhitespace(bytes[offset])) {
offset++;
}
return offset < bytes.length && bytes[offset] == '<';
}
/**
* KeySelector simples para resolução de chave pública em assinaturas XML via KeyInfo.
*/
private static class KeyValueKeySelector extends javax.xml.crypto.KeySelector {
@Override
public javax.xml.crypto.KeySelectorResult select(javax.xml.crypto.dsig.keyinfo.KeyInfo keyInfo,
Purpose purpose,
javax.xml.crypto.AlgorithmMethod method,
javax.xml.crypto.XMLCryptoContext context) {
if (keyInfo == null) return () -> null;
for (Object info : keyInfo.getContent()) {
if (info instanceof javax.xml.crypto.dsig.keyinfo.KeyValue kv) {
try {
java.security.PublicKey pk = kv.getPublicKey();
return () -> pk;
} catch (Exception ignored) {
}
} else if (info instanceof javax.xml.crypto.dsig.keyinfo.X509Data x509Data) {
for (Object o : x509Data.getContent()) {
if (o instanceof X509Certificate cert) {
return cert::getPublicKey;
}
}
}
}
return () -> null;
}
}
}
@@ -0,0 +1,19 @@
package br.com.cybercore.signer.core.validation;
import java.util.List;
/**
* Resultado completo da validação de uma assinatura digital (incorporada ou destacada).
*/
public record ValidationResult(
ValidationStatus status,
ValidationType type,
String summaryMessage,
List<ValidationSignerInfo> signers,
byte[] extractedContent,
String documentName
) {
public boolean isValid() {
return status == ValidationStatus.VALID;
}
}
@@ -0,0 +1,20 @@
package br.com.cybercore.signer.core.validation;
/**
* Informações e resultado da validação criptográfica de um signatário individual.
*/
public record ValidationSignerInfo(
String signerName,
String identifier,
String issuer,
String signingTime,
boolean digestValid,
boolean signatureValid,
String algorithm,
String certificateSubject,
String details
) {
public boolean isValid() {
return digestValid && signatureValid;
}
}
@@ -0,0 +1,21 @@
package br.com.cybercore.signer.core.validation;
/**
* Status do resultado da validação criptográfica.
*/
public enum ValidationStatus {
VALID("VÁLIDA"),
INVALID("INVÁLIDA"),
WARNING("VÁLIDA COM RESSALVAS"),
ERROR("ERRO DE PROCESSAMENTO");
private final String description;
ValidationStatus(String description) {
this.description = description;
}
public String getDescription() {
return description;
}
}
@@ -0,0 +1,28 @@
package br.com.cybercore.signer.core.validation;
/**
* Tipos de assinatura digital suportados para validação.
*/
public enum ValidationType {
DETACHED_CMS("Assinatura Destacada (CAdES / PKCS#7)", false),
ATTACHED_CMS("Assinatura Incorporada (CMS / P7M)", true),
PADES_PDF("Assinatura Incorporada em PDF (PAdES)", true),
XML_DSIG("Assinatura Incorporada em XML (XML-DSig)", true),
UNKNOWN("Formato Desconhecido", false);
private final String description;
private final boolean isEmbedded;
ValidationType(String description, boolean isEmbedded) {
this.description = description;
this.isEmbedded = isEmbedded;
}
public String getDescription() {
return description;
}
public boolean isEmbedded() {
return isEmbedded;
}
}
@@ -0,0 +1,32 @@
package br.com.cybercore.signer.core.xml;
import java.util.Objects;
/**
* Parâmetros de configuração para a operação de assinatura digital XMLDSig.
*/
public record XmlSignatureOptions(
String certificateId,
char[] pinOrPassword,
String targetElementTag,
String targetElementId,
String signatureAlgorithm
) {
public static final String DEFAULT_ALGORITHM = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256";
public XmlSignatureOptions {
Objects.requireNonNull(certificateId, "ID do certificado não pode ser nulo.");
if (signatureAlgorithm == null || signatureAlgorithm.isBlank()) {
signatureAlgorithm = DEFAULT_ALGORITHM;
}
}
public static XmlSignatureOptions wholeDocument(String certificateId, char[] pinOrPassword) {
return new XmlSignatureOptions(certificateId, pinOrPassword, null, null, DEFAULT_ALGORITHM);
}
public static XmlSignatureOptions forElement(String certificateId, char[] pinOrPassword, String tag, String id) {
return new XmlSignatureOptions(certificateId, pinOrPassword, tag, id, DEFAULT_ALGORITHM);
}
}
@@ -0,0 +1,290 @@
package br.com.cybercore.signer.core.xml;
import br.com.cybercore.signer.core.domain.ManagedCertificate;
import br.com.cybercore.signer.core.domain.SigningKeyMaterial;
import br.com.cybercore.signer.core.protocol.CcspErrorCode;
import br.com.cybercore.signer.core.provider.CertificateManager;
import br.com.cybercore.signer.core.provider.CertificateProviderException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import javax.xml.crypto.AlgorithmMethod;
import javax.xml.crypto.KeySelector;
import javax.xml.crypto.KeySelectorException;
import javax.xml.crypto.KeySelectorResult;
import javax.xml.crypto.XMLCryptoContext;
import javax.xml.crypto.dsig.CanonicalizationMethod;
import javax.xml.crypto.dsig.DigestMethod;
import javax.xml.crypto.dsig.Reference;
import javax.xml.crypto.dsig.SignatureMethod;
import javax.xml.crypto.dsig.SignedInfo;
import javax.xml.crypto.dsig.Transform;
import javax.xml.crypto.dsig.XMLSignature;
import javax.xml.crypto.dsig.XMLSignatureFactory;
import javax.xml.crypto.dsig.dom.DOMSignContext;
import javax.xml.crypto.dsig.dom.DOMValidateContext;
import javax.xml.crypto.dsig.keyinfo.KeyInfo;
import javax.xml.crypto.dsig.keyinfo.KeyInfoFactory;
import javax.xml.crypto.dsig.keyinfo.X509Data;
import javax.xml.crypto.dsig.spec.C14NMethodParameterSpec;
import javax.xml.crypto.dsig.spec.TransformParameterSpec;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.transform.OutputKeys;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.nio.file.Files;
import java.security.Key;
import java.security.PublicKey;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
/**
* Serviço de alto nível para Assinatura Digital de Documentos XML segundo o padrão W3C XMLDSig e ICP-Brasil.
* Suporta assinatura enveloped de documento completo e assinatura de elementos por ID (NF-e, SPED, etc.).
* Utiliza o suporte de fábrica nativo e seguro da plataforma Java (javax.xml.crypto.dsig).
*/
public class XmlSigningService {
private static final Logger logger = LoggerFactory.getLogger(XmlSigningService.class);
private final CertificateManager certificateManager;
public XmlSigningService(CertificateManager certificateManager) {
this.certificateManager = Objects.requireNonNull(certificateManager, "CertificateManager não pode ser nulo.");
}
/**
* Assina digitalmente um arquivo XML e grava no arquivo de destino.
*/
public void signXmlFile(File sourceXml, File targetXml, XmlSignatureOptions options) throws Exception {
Objects.requireNonNull(sourceXml, "Arquivo XML de origem não pode ser nulo.");
Objects.requireNonNull(targetXml, "Arquivo XML de destino não pode ser nulo.");
Objects.requireNonNull(options, "Opções de assinatura não podem ser nulas.");
byte[] inputBytes = Files.readAllBytes(sourceXml.toPath());
byte[] signedBytes = signXml(inputBytes, options);
if (targetXml.getParentFile() != null) {
targetXml.getParentFile().mkdirs();
}
try (FileOutputStream fos = new FileOutputStream(targetXml)) {
fos.write(signedBytes);
}
logger.info("XML assinado com sucesso e salvo em: {}", targetXml.getAbsolutePath());
}
/**
* Assina digitalmente o documento XML contido em bytes segundo as opções configuradas.
*/
public byte[] signXml(byte[] xmlBytes, XmlSignatureOptions options) throws Exception {
Objects.requireNonNull(xmlBytes, "Bytes do XML não podem ser nulos.");
Objects.requireNonNull(options, "Opções de assinatura não podem ser nulas.");
// 1. Resolução do Certificado e Chave Privada
Optional<ManagedCertificate> certOpt = certificateManager.findById(options.certificateId());
if (certOpt.isEmpty()) {
certOpt = certificateManager.findByHint(options.certificateId());
}
if (certOpt.isEmpty()) {
throw new CertificateProviderException(
CcspErrorCode.CERTIFICATE_NOT_FOUND,
"Certificado com ID '" + options.certificateId() + "' não encontrado."
);
}
ManagedCertificate cert = certOpt.get();
Optional<SigningKeyMaterial> keyMaterialOpt = certificateManager.getKeyMaterial(cert.id(), options.pinOrPassword());
if (keyMaterialOpt.isEmpty()) {
throw new CertificateProviderException(
CcspErrorCode.PRIVATE_KEY_NOT_AVAILABLE,
"Chave privada inacessível para o certificado '" + cert.alias() + "'."
);
}
SigningKeyMaterial keyMaterial = keyMaterialOpt.get();
logger.info("Iniciando assinatura XMLDSig com certificado '{}' [tipo={}]", cert.commonName(), cert.type());
// 2. Parse Seguro do XML (Prevenção rigorosa de XXE)
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
dbf.setNamespaceAware(true);
dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
dbf.setXIncludeAware(false);
dbf.setExpandEntityReferences(false);
DocumentBuilder db = dbf.newDocumentBuilder();
Document doc = db.parse(new ByteArrayInputStream(xmlBytes));
// 3. Configuração do XMLSignatureFactory
XMLSignatureFactory fac = XMLSignatureFactory.getInstance("DOM");
// Transformações padrão: Enveloped + C14N
List<Transform> transforms = new ArrayList<>();
transforms.add(fac.newTransform(Transform.ENVELOPED, (TransformParameterSpec) null));
transforms.add(fac.newTransform(CanonicalizationMethod.INCLUSIVE, (TransformParameterSpec) null));
// Determinação da Referência e Ponto de Inserção
Reference ref;
Node insertionParent;
if (options.targetElementId() != null && !options.targetElementId().isBlank()) {
String elemId = options.targetElementId();
Element targetElement = findElementById(doc, elemId, options.targetElementTag());
if (targetElement == null) {
throw new IllegalArgumentException("Elemento XML com ID '" + elemId + "' não encontrado no documento.");
}
targetElement.setIdAttribute("Id", true);
ref = fac.newReference("#" + elemId, fac.newDigestMethod(DigestMethod.SHA256, null), transforms, null, null);
insertionParent = (targetElement.getParentNode() != null) ? targetElement.getParentNode() : doc.getDocumentElement();
} else if (options.targetElementTag() != null && !options.targetElementTag().isBlank()) {
NodeList list = doc.getElementsByTagName(options.targetElementTag());
if (list.getLength() == 0) {
list = doc.getElementsByTagNameNS("*", options.targetElementTag());
}
if (list.getLength() > 0 && list.item(0) instanceof Element elem && elem.hasAttribute("Id")) {
String idVal = elem.getAttribute("Id");
elem.setIdAttribute("Id", true);
ref = fac.newReference("#" + idVal, fac.newDigestMethod(DigestMethod.SHA256, null), transforms, null, null);
insertionParent = (elem.getParentNode() != null) ? elem.getParentNode() : doc.getDocumentElement();
} else {
ref = fac.newReference("", fac.newDigestMethod(DigestMethod.SHA256, null), transforms, null, null);
insertionParent = doc.getDocumentElement();
}
} else {
// Assinatura Enveloped do documento completo
ref = fac.newReference("", fac.newDigestMethod(DigestMethod.SHA256, null), transforms, null, null);
insertionParent = doc.getDocumentElement();
}
// 4. Montagem do SignedInfo
SignedInfo si = fac.newSignedInfo(
fac.newCanonicalizationMethod(CanonicalizationMethod.INCLUSIVE, (C14NMethodParameterSpec) null),
fac.newSignatureMethod(options.signatureAlgorithm(), null),
Collections.singletonList(ref)
);
// 5. Inclusão da Cadeia de Certificados no KeyInfo
KeyInfoFactory kif = fac.getKeyInfoFactory();
List<Object> x509Content = new ArrayList<>();
x509Content.add(keyMaterial.signingCertificate());
X509Data x509Data = kif.newX509Data(x509Content);
KeyInfo ki = kif.newKeyInfo(Collections.singletonList(x509Data));
// 6. Execução da Assinatura DOM
DOMSignContext dsc = new DOMSignContext(keyMaterial.privateKey(), insertionParent);
if (keyMaterial.jcaProvider() != null) {
dsc.setProperty("org.jcp.xml.dsig.internal.dom.SignatureProvider", keyMaterial.jcaProvider());
}
XMLSignature signature = fac.newXMLSignature(si, ki);
signature.sign(dsc);
// 7. Serialização do XML Assinado
TransformerFactory tf = TransformerFactory.newInstance();
Transformer trans = tf.newTransformer();
trans.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "no");
trans.setOutputProperty(OutputKeys.ENCODING, "UTF-8");
trans.setOutputProperty(OutputKeys.INDENT, "no");
ByteArrayOutputStream baos = new ByteArrayOutputStream();
trans.transform(new DOMSource(doc), new StreamResult(baos));
logger.info("Assinatura XMLDSig gerada com sucesso. Tamanho: {} bytes", baos.size());
return baos.toByteArray();
}
/**
* Valida a integridade criptográfica de um documento XML assinado com XMLDSig.
*/
public boolean validateXmlSignature(byte[] signedXmlBytes) throws Exception {
Objects.requireNonNull(signedXmlBytes, "Bytes do XML assinado não podem ser nulos.");
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
dbf.setNamespaceAware(true);
dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
Document doc = dbf.newDocumentBuilder().parse(new ByteArrayInputStream(signedXmlBytes));
// Registra nós com atributo Id/id/ID para que o ResourceResolver do XMLDSig localize a referência
NodeList allElements = doc.getElementsByTagName("*");
for (int i = 0; i < allElements.getLength(); i++) {
if (allElements.item(i) instanceof Element e) {
if (e.hasAttribute("Id")) {
e.setIdAttribute("Id", true);
} else if (e.hasAttribute("id")) {
e.setIdAttribute("id", true);
} else if (e.hasAttribute("ID")) {
e.setIdAttribute("ID", true);
}
}
}
NodeList nl = doc.getElementsByTagNameNS(XMLSignature.XMLNS, "Signature");
if (nl.getLength() == 0) {
nl = doc.getElementsByTagName("Signature");
}
if (nl.getLength() == 0) {
throw new IllegalArgumentException("Nenhum elemento <Signature> encontrado no XML para validação.");
}
Element sigElement = (Element) nl.item(0);
XMLSignatureFactory fac = XMLSignatureFactory.getInstance("DOM");
DOMValidateContext valContext = new DOMValidateContext(new X509KeySelector(), sigElement);
XMLSignature signature = fac.unmarshalXMLSignature(valContext);
return signature.validate(valContext);
}
private static Element findElementById(Document doc, String id, String hintTag) {
if (hintTag != null && !hintTag.isBlank()) {
NodeList nl = doc.getElementsByTagName(hintTag);
for (int i = 0; i < nl.getLength(); i++) {
if (nl.item(i) instanceof Element e && id.equals(e.getAttribute("Id"))) {
return e;
}
}
}
return doc.getElementById(id);
}
/**
* Seletor de chave pública baseado no certificado X.509 embutido no KeyInfo.
*/
private static class X509KeySelector extends KeySelector {
@Override
public KeySelectorResult select(KeyInfo keyInfo, KeySelector.Purpose purpose,
AlgorithmMethod method, XMLCryptoContext context) throws KeySelectorException {
if (keyInfo == null) {
throw new KeySelectorException("Elemento KeyInfo ausente na assinatura XML.");
}
for (Object info : keyInfo.getContent()) {
if (info instanceof X509Data x509Data) {
for (Object o : x509Data.getContent()) {
if (o instanceof X509Certificate cert) {
PublicKey pk = cert.getPublicKey();
return () -> pk;
}
}
}
}
throw new KeySelectorException("Nenhuma chave pública X.509 encontrada no KeyInfo da assinatura.");
}
}
}
@@ -0,0 +1,47 @@
package br.com.cybercore.signer.core.config;
import br.com.cybercore.signer.core.pdf.seal.CpfDisplayMode;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("Testes de Configurações do Agente (AgentConfigurationService)")
class AgentConfigurationServiceTest {
@TempDir
Path tempDir;
@Test
@DisplayName("Deve salvar e recuperar configurações personalizadas do agente")
void shouldSaveAndLoadConfigurations() {
Path configFile = tempDir.resolve("config.json");
AgentConfigurationService service = new AgentConfigurationService(configFile);
AgentConfiguration customConfig = new AgentConfiguration(
false,
true,
true,
"cybercore-modern",
CpfDisplayMode.MASKED,
false,
18420
);
service.updateConfig(customConfig);
// Novo serviço lendo o mesmo arquivo
AgentConfigurationService reloadedService = new AgentConfigurationService(configFile);
AgentConfiguration loaded = reloadedService.getConfig();
assertThat(loaded.requireConfirmation()).isFalse();
assertThat(loaded.alwaysPromptPin()).isTrue();
assertThat(loaded.autoStart()).isTrue();
assertThat(loaded.defaultSealThemeId()).isEqualTo("cybercore-modern");
assertThat(loaded.defaultCpfDisplayMode()).isEqualTo(CpfDisplayMode.MASKED);
assertThat(loaded.defaultIncludeDateTime()).isFalse();
}
}
@@ -0,0 +1,45 @@
package br.com.cybercore.signer.core.crypto;
import br.com.cybercore.signer.core.protocol.CcspErrorCode;
import br.com.cybercore.signer.core.provider.CertificateProviderException;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class DigestAlgorithmTest {
@Test
@DisplayName("Deve identificar algoritmos suportados pelo nome JCA")
void shouldFindSupportedAlgorithm() throws Exception {
DigestAlgorithm alg = DigestAlgorithm.fromName("SHA256withRSA");
assertThat(alg).isEqualTo(DigestAlgorithm.SHA256_WITH_RSA);
assertThat(alg.getExpectedDigestLengthBytes()).isEqualTo(32);
DigestAlgorithm algEc = DigestAlgorithm.fromName("SHA384withECDSA");
assertThat(algEc).isEqualTo(DigestAlgorithm.SHA384_WITH_ECDSA);
assertThat(algEc.getExpectedDigestLengthBytes()).isEqualTo(48);
}
@Test
@DisplayName("Deve lançar CertificateProviderException para algoritmo desconhecido")
void shouldFailOnUnknownAlgorithm() {
assertThatThrownBy(() -> DigestAlgorithm.fromName("MD5withRSA"))
.isInstanceOf(CertificateProviderException.class)
.hasFieldOrPropertyWithValue("errorCode", CcspErrorCode.UNSUPPORTED_ALGORITHM);
}
@Test
@DisplayName("Deve validar comprimento correto do hash digest")
void shouldValidateDigestLength() throws Exception {
DigestAlgorithm alg = DigestAlgorithm.SHA256_WITH_RSA;
byte[] validSha256 = new byte[32];
alg.validateDigestLength(validSha256);
byte[] invalidLength = new byte[16];
assertThatThrownBy(() -> alg.validateDigestLength(invalidLength))
.isInstanceOf(CertificateProviderException.class)
.hasFieldOrPropertyWithValue("errorCode", CcspErrorCode.SIGNATURE_FAILED);
}
}
@@ -0,0 +1,147 @@
package br.com.cybercore.signer.core.crypto;
import br.com.cybercore.signer.core.domain.CertificateStatus;
import br.com.cybercore.signer.core.domain.CertificateType;
import br.com.cybercore.signer.core.domain.ManagedCertificate;
import br.com.cybercore.signer.core.domain.SigningContext;
import br.com.cybercore.signer.core.domain.SigningResult;
import br.com.cybercore.signer.core.protocol.CcspErrorCode;
import br.com.cybercore.signer.core.provider.CertificateManager;
import br.com.cybercore.signer.core.provider.CertificateProvider;
import br.com.cybercore.signer.core.provider.CertificateProviderException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class SigningServiceTest {
private CertificateManager certificateManager;
private CertificateProvider mockProvider;
private SigningService signingService;
private ManagedCertificate validCert;
@BeforeEach
void setUp() throws Exception {
certificateManager = new CertificateManager();
mockProvider = mock(CertificateProvider.class);
when(mockProvider.getProviderId()).thenReturn("provider-mock");
when(mockProvider.isAvailable()).thenReturn(true);
validCert = new ManagedCertificate(
"cert-valid", "João da Silva", CertificateType.A3, CertificateStatus.AVAILABLE,
"CN=João da Silva:12345678909, O=ICP-Brasil, C=BR", "CN=AC Certisign, C=BR",
"ABC1234", "sha1_val", "sha256_val",
Instant.now().minus(30, ChronoUnit.DAYS), Instant.now().plus(300, ChronoUnit.DAYS),
"RSA", 2048, "João da Silva", "12345678909", null, "provider-mock", "Token USB"
);
when(mockProvider.listCertificates()).thenReturn(List.of(validCert));
certificateManager.registerProvider(mockProvider);
certificateManager.discoverCertificates();
signingService = new SigningService(certificateManager);
}
@Test
@DisplayName("Deve executar assinatura com sucesso delegando ao provider")
void shouldSignSuccessfully() throws Exception {
byte[] digest = new byte[32]; // SHA-256 digest
SigningContext context = new SigningContext(
"req-uuid-1", "app-erp", digest, "SHA256withRSA", "cert-valid", Instant.now()
);
SigningResult expectedResult = new SigningResult(
"req-uuid-1", new byte[]{1, 2, 3, 4}, "certBase64",
List.of("certBase64"), "SHA256withRSA", Instant.now()
);
when(mockProvider.sign(any())).thenReturn(expectedResult);
SigningResult actual = signingService.sign(context);
assertThat(actual).isNotNull();
assertThat(actual.requestId()).isEqualTo("req-uuid-1");
assertThat(actual.signatureBytes()).isEqualTo(new byte[]{1, 2, 3, 4});
}
@Test
@DisplayName("Deve rejeitar requisição sem request_id")
void shouldRejectMissingRequestId() {
byte[] digest = new byte[32];
assertThatThrownBy(() -> new SigningContext(
null, "app-erp", digest, "SHA256withRSA", "cert-valid", Instant.now()
)).isInstanceOf(NullPointerException.class);
}
@Test
@DisplayName("Deve falhar ao tentar assinar com algoritmo não suportado")
void shouldFailOnUnsupportedAlgorithm() {
byte[] digest = new byte[32];
SigningContext context = new SigningContext(
"req-2", "app-erp", digest, "MD5withRSA", "cert-valid", Instant.now()
);
assertThatThrownBy(() -> signingService.sign(context))
.isInstanceOf(CertificateProviderException.class)
.hasFieldOrPropertyWithValue("errorCode", CcspErrorCode.UNSUPPORTED_ALGORITHM);
}
@Test
@DisplayName("Deve falhar se o certificado estiver expirado")
void shouldFailOnExpiredCertificate() throws Exception {
ManagedCertificate expiredCert = new ManagedCertificate(
"cert-exp", "Maria Expirada", CertificateType.A1, CertificateStatus.EXPIRED,
"CN=Maria Expirada, C=BR", "CN=AC Raiz, C=BR",
"EXP123", "sha1_exp", "sha256_exp",
Instant.now().minus(300, ChronoUnit.DAYS), Instant.now().minus(10, ChronoUnit.DAYS),
"RSA", 2048, "Maria Expirada", null, null, "provider-mock", null
);
when(mockProvider.listCertificates()).thenReturn(List.of(expiredCert));
certificateManager.discoverCertificates();
byte[] digest = new byte[32];
SigningContext context = new SigningContext(
"req-3", "app-erp", digest, "SHA256withRSA", "cert-exp", Instant.now()
);
assertThatThrownBy(() -> signingService.sign(context))
.isInstanceOf(CertificateProviderException.class)
.hasFieldOrPropertyWithValue("errorCode", CcspErrorCode.CERTIFICATE_EXPIRED);
}
@Test
@DisplayName("Deve falhar se o certificado não possuir chave privada")
void shouldFailWhenPrivateKeyNotAvailable() throws Exception {
ManagedCertificate noKeyCert = new ManagedCertificate(
"cert-nokey", "Certificado Público", CertificateType.A1, CertificateStatus.NO_PRIVATE_KEY,
"CN=Certificado Público, C=BR", "CN=AC Raiz, C=BR",
"NOKEY123", "sha1_nokey", "sha256_nokey",
Instant.now().minus(10, ChronoUnit.DAYS), Instant.now().plus(300, ChronoUnit.DAYS),
"RSA", 2048, "Certificado Público", null, null, "provider-mock", null
);
when(mockProvider.listCertificates()).thenReturn(List.of(noKeyCert));
certificateManager.discoverCertificates();
byte[] digest = new byte[32];
SigningContext context = new SigningContext(
"req-4", "app-erp", digest, "SHA256withRSA", "cert-nokey", Instant.now()
);
assertThatThrownBy(() -> signingService.sign(context))
.isInstanceOf(CertificateProviderException.class)
.hasFieldOrPropertyWithValue("errorCode", CcspErrorCode.PRIVATE_KEY_NOT_AVAILABLE);
}
}
@@ -0,0 +1,22 @@
package br.com.cybercore.signer.core.diagnostics;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class DiagnosticServiceTest {
@Test
@DisplayName("Deve gerar relatório de diagnóstico com sucesso e validar crypto engine")
void shouldGenerateDiagnosticReport() {
DiagnosticService service = new DiagnosticService();
DiagnosticReport report = service.runDiagnostic();
assertThat(report).isNotNull();
assertThat(report.cryptoEngineOk()).isTrue();
assertThat(report.javaVersion()).isNotBlank();
assertThat(report.registeredSecurityProviders()).isNotEmpty();
assertThat(report.additionalDetails()).containsKey("VirtualThreadsSupported");
}
}
@@ -0,0 +1,218 @@
package br.com.cybercore.signer.core.pdf;
import br.com.cybercore.signer.core.domain.ManagedCertificate;
import br.com.cybercore.signer.core.pdf.seal.PdfSealTheme;
import br.com.cybercore.signer.core.pdf.seal.PdfSealThemeRegistry;
import br.com.cybercore.signer.core.provider.CertificateManager;
import br.com.cybercore.signer.core.provider.Pkcs12CertificateProvider;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.PDSignature;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.cert.X509v3CertificateBuilder;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayOutputStream;
import java.math.BigInteger;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.KeyStore;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Date;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class PdfSigningServiceTest {
private CertificateManager certificateManager;
private PdfSigningService pdfSigningService;
private String testCertId;
@BeforeEach
void setUp() throws Exception {
certificateManager = new CertificateManager();
// Gera par de chaves e certificado X.509 em memória
KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
kpg.initialize(2048);
KeyPair kp = kpg.generateKeyPair();
long now = System.currentTimeMillis();
Date notBefore = new Date(now - 100000);
Date notAfter = new Date(now + 365L * 24 * 3600 * 1000);
X500Name subject = new X500Name("CN=Joao da Silva:12345678900, O=CyberCore Testes, C=BR");
X509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder(
subject,
BigInteger.valueOf(System.currentTimeMillis()),
notBefore,
notAfter,
subject,
kp.getPublic()
);
ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").build(kp.getPrivate());
X509Certificate cert = new JcaX509CertificateConverter().getCertificate(certBuilder.build(signer));
// Cria KeyStore PKCS12 em memória
KeyStore ks = KeyStore.getInstance("PKCS12");
ks.load(null, null);
char[] pwd = "password123".toCharArray();
ks.setKeyEntry("test-cert", kp.getPrivate(), pwd, new Certificate[]{cert});
Pkcs12CertificateProvider provider = new Pkcs12CertificateProvider("test-provider", "Provedor de Teste");
provider.setKeyStore(ks, pwd);
certificateManager.registerProvider(provider);
List<ManagedCertificate> list = certificateManager.discoverCertificates();
assertThat(list).isNotEmpty();
this.testCertId = list.get(0).id();
this.pdfSigningService = new PdfSigningService(certificateManager);
}
private byte[] createSamplePdf() throws Exception {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage();
doc.addPage(page);
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
cs.beginText();
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA_BOLD), 14);
cs.newLineAtOffset(100, 700);
cs.showText("Documento de Contrato de Teste - CyberCore Signer");
cs.endText();
}
doc.save(baos);
}
return baos.toByteArray();
}
@Test
@DisplayName("Deve assinar documento PDF aplicando selo visual com o tema ICP-Brasil")
void shouldSignPdfWithIcpBrasilVisualSeal() throws Exception {
byte[] originalPdf = createSamplePdf();
PdfSealTheme theme = PdfSealThemeRegistry.findById("icp-brasil").orElseThrow();
PdfSignatureOptions options = PdfSignatureOptions.builder(testCertId)
.sealTheme(theme)
.visibleSeal(true)
.position(PdfSealPosition.BOTTOM_RIGHT)
.pageChoice(PdfSealPageChoice.LAST_PAGE)
.reason("Aprovação Contratual")
.location("Brasília, DF")
.build();
byte[] signedPdf = pdfSigningService.signPdf(originalPdf, options);
assertThat(signedPdf).isNotNull();
assertThat(signedPdf.length).isGreaterThan(originalPdf.length);
// Valida se o PDF possui a assinatura PDSignature incorporada
try (PDDocument signedDoc = Loader.loadPDF(signedPdf)) {
List<PDSignature> signatures = signedDoc.getSignatureDictionaries();
assertThat(signatures).hasSize(1);
PDSignature sig = signatures.get(0);
assertThat(sig.getName()).contains("Joao da Silva");
assertThat(sig.getReason()).isEqualTo("Aprovação Contratual");
assertThat(sig.getLocation()).isEqualTo("Brasília, DF");
}
}
@Test
@DisplayName("Deve suportar todos os temas de selos visuais em SVG (CyberCore, Minimalista, Gov.br)")
void shouldSupportAllVisualSealThemes() throws Exception {
byte[] originalPdf = createSamplePdf();
for (PdfSealTheme theme : PdfSealThemeRegistry.listThemes()) {
PdfSignatureOptions options = PdfSignatureOptions.builder(testCertId)
.sealTheme(theme)
.visibleSeal(true)
.position(PdfSealPosition.BOTTOM_LEFT)
.build();
byte[] signed = pdfSigningService.signPdf(originalPdf, options);
assertThat(signed).isNotNull();
try (PDDocument doc = Loader.loadPDF(signed)) {
assertThat(doc.getSignatureDictionaries()).hasSize(1);
}
}
}
@Test
@DisplayName("Deve assinar PDF no modo invisível (sem selo gráfico)")
void shouldSignPdfInvisibleWithoutSeal() throws Exception {
byte[] originalPdf = createSamplePdf();
PdfSignatureOptions options = PdfSignatureOptions.builder(testCertId)
.visibleSeal(false)
.build();
byte[] signedPdf = pdfSigningService.signPdf(originalPdf, options);
try (PDDocument doc = Loader.loadPDF(signedPdf)) {
assertThat(doc.getSignatureDictionaries()).hasSize(1);
}
}
@Test
@DisplayName("Deve assinar PDF com coordenadas customizadas e página específica")
void shouldSignPdfWithCustomCoordinatesAndPage() throws Exception {
byte[] originalPdf = createSamplePdf();
PdfSignatureOptions options = PdfSignatureOptions.builder(testCertId)
.visibleSeal(true)
.customPosition(120.5f, 250.0f, 1)
.reason("Assinatura em local personalizado")
.location("São Paulo, SP")
.build();
assertThat(options.position()).isEqualTo(PdfSealPosition.CUSTOM);
assertThat(options.customX()).isEqualTo(120.5f);
assertThat(options.customY()).isEqualTo(250.0f);
assertThat(options.targetPageNumber()).isEqualTo(1);
byte[] signedPdf = pdfSigningService.signPdf(originalPdf, options);
assertThat(signedPdf).isNotNull();
try (PDDocument doc = Loader.loadPDF(signedPdf)) {
assertThat(doc.getSignatureDictionaries()).hasSize(1);
PDSignature sig = doc.getSignatureDictionaries().get(0);
assertThat(sig.getReason()).isEqualTo("Assinatura em local personalizado");
}
}
@Test
@DisplayName("Deve contar páginas e renderizar imagem da página do PDF")
void shouldCountPagesAndRenderPageImage() throws Exception {
byte[] originalPdf = createSamplePdf();
int count = PdfSigningService.getPageCount(originalPdf);
assertThat(count).isEqualTo(1);
java.awt.image.BufferedImage img = PdfSigningService.renderPageImage(originalPdf, 0, 72f);
assertThat(img).isNotNull();
assertThat(img.getWidth()).isGreaterThan(100);
assertThat(img.getHeight()).isGreaterThan(100);
byte[] pngBytes = PdfSigningService.renderPagePngBytes(originalPdf, 0, 72f);
assertThat(pngBytes).isNotEmpty();
}
}
@@ -0,0 +1,31 @@
package br.com.cybercore.signer.core.pdf.seal;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("Testes do Modo de Exibição de CPF nos Selos (CpfDisplayMode)")
class CpfDisplayModeTest {
@Test
@DisplayName("Deve formatar CPF completo com pontuação padrão")
void shouldFormatFullCpf() {
String formatted = CpfDisplayMode.FULL.formatCpf("12345678900");
assertThat(formatted).isEqualTo("123.456.789-00");
}
@Test
@DisplayName("Deve formatar CPF mascarado protegendo dados sensíveis")
void shouldFormatMaskedCpf() {
String formatted = CpfDisplayMode.MASKED.formatCpf("12345678900");
assertThat(formatted).isEqualTo("***.456.789-**");
}
@Test
@DisplayName("Deve omitir CPF quando o modo for NONE")
void shouldOmitCpfWhenNone() {
String formatted = CpfDisplayMode.NONE.formatCpf("12345678900");
assertThat(formatted).isEmpty();
}
}
@@ -0,0 +1,137 @@
package br.com.cybercore.signer.core.pdf.seal;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Path;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("Testes de Armazenamento e Instalação de Selos Personalizados (CustomSealStorage)")
class CustomSealStorageTest {
@TempDir
Path tempDir;
@Test
@DisplayName("Deve salvar, listar e excluir selos personalizados com sucesso")
void shouldSaveListAndDeleteCustomSeal() throws IOException {
CustomSealStorage storage = new CustomSealStorage(tempDir);
PdfSealTheme customTheme = new PdfSealTheme(
"selo-meu-escritorio",
"Meu Escritório de Advocacia",
"Selo personalizado com brasão dourado e fundo escuro",
320,
82,
"#b45309",
"#d97706",
"#ffffff",
"#fde68a",
"#1f2937",
null,
"ADVOCACIA SILVA • ASSINADO DIGITALMENTE",
"Validade jurídica plena conforme MP nº 2.200-2/2001",
"notary",
CpfDisplayMode.MASKED,
true,
true
);
storage.saveSeal(customTheme);
List<PdfSealTheme> seals = storage.loadAllCustomSeals();
assertThat(seals).hasSize(1);
PdfSealTheme loaded = seals.get(0);
assertThat(loaded.id()).isEqualTo("selo-meu-escritorio");
assertThat(loaded.name()).isEqualTo("Meu Escritório de Advocacia");
assertThat(loaded.customHeader()).isEqualTo("ADVOCACIA SILVA • ASSINADO DIGITALMENTE");
assertThat(loaded.defaultCpfMode()).isEqualTo(CpfDisplayMode.MASKED);
assertThat(loaded.isCustom()).isTrue();
boolean deleted = storage.deleteSeal("selo-meu-escritorio");
assertThat(deleted).isTrue();
assertThat(storage.loadAllCustomSeals()).isEmpty();
}
@Test
@DisplayName("Deve salvar e carregar selo customizado com logotipo em imagem Base64")
void shouldSaveAndLoadCustomSealWithCustomLogo() throws IOException {
CustomSealStorage storage = new CustomSealStorage(tempDir);
String sampleBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
PdfSealTheme customWithLogo = new PdfSealTheme(
"selo-logo-propria",
"Selo com Logo Própria",
"Selo para empresa com logotipo PNG em Base64",
320,
82,
"#1e3a8a",
"#3b82f6",
"#ffffff",
"#93c5fd",
"#172554",
null,
"MEU ESCRITÓRIO • ASSINADO DIGITALMENTE",
"Validade jurídica plena conforme MP nº 2.200-2/2001",
"shield",
CpfDisplayMode.FULL,
true,
true,
sampleBase64
);
storage.saveSeal(customWithLogo);
List<PdfSealTheme> seals = storage.loadAllCustomSeals();
assertThat(seals).hasSize(1);
PdfSealTheme loaded = seals.get(0);
assertThat(loaded.id()).isEqualTo("selo-logo-propria");
assertThat(loaded.customLogoBase64()).isEqualTo(sampleBase64);
assertThat(loaded.isCustom()).isTrue();
}
@Test
@DisplayName("Deve salvar e carregar selo customizado com campos customizados (ex: Matrícula / Cargo / OAB)")
void shouldSaveAndLoadCustomSealWithCustomFields() throws IOException {
CustomSealStorage storage = new CustomSealStorage(tempDir);
PdfSealTheme customWithFields = new PdfSealTheme(
"selo-servidor-publico",
"Selo Servidor Público",
"Selo para auditores e servidores com matrícula funcional",
320,
82,
"#065f46",
"#059669",
"#ffffff",
"#a7f3d0",
"#064e3b",
null,
"TRIBUNAL DE CONTAS • AUDITORIA FISCAL",
"Autenticidade ICP-Brasil conferida",
"shield",
CpfDisplayMode.MASKED,
true,
true,
null,
"Matrícula",
"984210-AUD"
);
storage.saveSeal(customWithFields);
List<PdfSealTheme> seals = storage.loadAllCustomSeals();
assertThat(seals).hasSize(1);
PdfSealTheme loaded = seals.get(0);
assertThat(loaded.id()).isEqualTo("selo-servidor-publico");
assertThat(loaded.customFieldLabel()).isEqualTo("Matrícula");
assertThat(loaded.customFieldValue()).isEqualTo("984210-AUD");
assertThat(loaded.formatCustomField()).isEqualTo("Matrícula: 984210-AUD");
}
}
@@ -0,0 +1,140 @@
package br.com.cybercore.signer.core.pdf.seal;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.awt.image.BufferedImage;
import java.io.IOException;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("Testes de Renderização de Selos Visuais (PdfSealRenderer)")
class PdfSealRendererTest {
@Test
@DisplayName("Deve renderizar selo visual em alta resolução com sucesso")
void shouldRenderSealImageInHighResolution() throws IOException {
PdfSealTheme theme = PdfSealThemeRegistry.getDefaultTheme();
PdfSealData data = new PdfSealData(
"Dr. Carlos Alberto de Oliveira Santos Júnior",
"CPF: 12345678900",
null,
"AC CyberCore JUS v5",
"8A7F4B2C91D0E3F5",
"Assinatura Digital",
"São Paulo, SP",
CpfDisplayMode.MASKED,
true
);
BufferedImage img = PdfSealRenderer.renderToImage(theme, data);
assertThat(img).isNotNull();
// Escala 2x: 320 * 2 = 640, 82 * 2 = 164
assertThat(img.getWidth()).isEqualTo(640);
assertThat(img.getHeight()).isEqualTo(164);
byte[] pngBytes = PdfSealRenderer.renderToPngBytes(theme, data);
assertThat(pngBytes).isNotEmpty();
// Header PNG: 0x89 0x50 0x4E 0x47
assertThat(pngBytes[0]).isEqualTo((byte) 0x89);
assertThat(pngBytes[1]).isEqualTo((byte) 0x50);
assertThat(pngBytes[2]).isEqualTo((byte) 0x4E);
assertThat(pngBytes[3]).isEqualTo((byte) 0x47);
}
@Test
@DisplayName("Deve renderizar selo com logotipo personalizado em Base64")
void shouldRenderSealWithCustomLogoBase64() throws IOException {
// Pixel PNG 1x1 em Base64
String sampleBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
PdfSealTheme themeWithLogo = new PdfSealTheme(
"custom-seal",
"Selo com Logo Própria",
"Descrição do selo",
320,
82,
"#0d47a1",
"#0284c7",
"#ffffff",
"#cbd5e1",
"#0f172a",
null,
"MEU ESCRITÓRIO • ASSINADO DIGITALMENTE",
"Validade jurídica plena conforme MP nº 2.200-2/2001",
"shield",
CpfDisplayMode.FULL,
false,
true,
sampleBase64
);
PdfSealData data = new PdfSealData(
"Dra. Maria de Souza Ferreira",
"CPF: 98765432100",
null,
"AC Raiz ICP-Brasil",
"AABBCCDDEEFF0011",
"Assinatura de Contrato",
"Curitiba, PR",
CpfDisplayMode.FULL,
false
);
BufferedImage img = PdfSealRenderer.renderToImage(themeWithLogo, data);
assertThat(img).isNotNull();
assertThat(img.getWidth()).isEqualTo(640);
assertThat(img.getHeight()).isEqualTo(164);
byte[] pngBytes = PdfSealRenderer.renderToPngBytes(themeWithLogo, data);
assertThat(pngBytes).isNotEmpty();
}
@Test
@DisplayName("Deve renderizar selo quando CPF é omitido e sem data/hora (apenas nome em evidência)")
void shouldRenderSealWithOnlySignerName() throws IOException {
PdfSealTheme theme = PdfSealThemeRegistry.getDefaultTheme();
PdfSealData data = new PdfSealData(
"Gabriel Soares de Alcantara",
"CPF: 11122233344",
null,
"AC OAB v3",
"12345678",
"Assinatura",
"Brasília, DF",
CpfDisplayMode.NONE,
false
);
BufferedImage img = PdfSealRenderer.renderToImage(theme, data);
assertThat(img).isNotNull();
assertThat(img.getWidth()).isEqualTo(640);
assertThat(img.getHeight()).isEqualTo(164);
}
@Test
@DisplayName("Deve renderizar selo com informação customizada (ex: Matrícula / Cargo) na linha de identificação")
void shouldRenderSealWithCustomExtraInfo() throws IOException {
PdfSealTheme theme = PdfSealThemeRegistry.getDefaultTheme();
PdfSealData data = new PdfSealData(
"Dra. Juliana Mendes Silveira",
"CPF: 12345678900",
null,
"AC OAB Nacional",
"FEEDCBA012345678",
"Assinatura de Petição",
"São Paulo, SP",
CpfDisplayMode.MASKED,
true,
"OAB/SP 431.902"
);
BufferedImage img = PdfSealRenderer.renderToImage(theme, data);
assertThat(img).isNotNull();
assertThat(img.getWidth()).isEqualTo(640);
assertThat(img.getHeight()).isEqualTo(164);
byte[] pngBytes = PdfSealRenderer.renderToPngBytes(theme, data);
assertThat(pngBytes).isNotEmpty();
}
}
@@ -0,0 +1,198 @@
package br.com.cybercore.signer.core.protocol;
import br.com.cybercore.signer.core.crypto.SigningService;
import br.com.cybercore.signer.core.domain.ManagedCertificate;
import br.com.cybercore.signer.core.provider.CertificateManager;
import br.com.cybercore.signer.core.provider.Pkcs12CertificateProvider;
import br.com.cybercore.signer.core.security.ApplicationRegistry;
import br.com.cybercore.signer.core.security.AuthorizedApplication;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import javax.security.auth.x500.X500Principal;
import java.io.InputStream;
import java.io.OutputStream;
import java.math.BigInteger;
import java.net.HttpURLConnection;
import java.net.ServerSocket;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.KeyStore;
import java.security.MessageDigest;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Date;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.Callable;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@DisplayName("Testes de Concorrência e Multiaplicação do Protocolo CCSP/1 (CcspConcurrencyTest)")
class CcspConcurrencyTest {
@TempDir
Path tempDir;
private CcspServer server;
private int port;
private ApplicationRegistry registry;
private String certId;
private final ObjectMapper mapper = new ObjectMapper();
@BeforeEach
void setUp() throws Exception {
// Encontra porta livre
try (ServerSocket socket = new ServerSocket(0)) {
port = socket.getLocalPort();
}
registry = new ApplicationRegistry(tempDir.resolve("apps.json"));
// Cadastra 3 aplicações autorizadas distintas
registry.register(new AuthorizedApplication("app-erp", "ERP Corporativo", Set.of("http://localhost:3000"), true, Instant.now()));
registry.register(new AuthorizedApplication("app-juridico", "Portal Jurídico", Set.of("http://localhost:3001"), true, Instant.now()));
registry.register(new AuthorizedApplication("app-rh", "Sistema de RH", Set.of("http://localhost:3002"), true, Instant.now()));
KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
kpg.initialize(2048);
KeyPair keyPair = kpg.generateKeyPair();
X509Certificate cert = mock(X509Certificate.class);
when(cert.getSubjectX500Principal()).thenReturn(new X500Principal("CN=Empresa Concorrente:12345678000199, O=ICP-Brasil, C=BR"));
when(cert.getIssuerX500Principal()).thenReturn(new X500Principal("CN=AC Teste ICP-Brasil, C=BR"));
when(cert.getSerialNumber()).thenReturn(BigInteger.valueOf(987654321));
when(cert.getNotBefore()).thenReturn(Date.from(Instant.now().minus(10, ChronoUnit.DAYS)));
when(cert.getNotAfter()).thenReturn(Date.from(Instant.now().plus(300, ChronoUnit.DAYS)));
when(cert.getPublicKey()).thenReturn(keyPair.getPublic());
when(cert.getEncoded()).thenReturn(new byte[]{0x30, 0x10, 0x01, 0x02});
char[] password = "concurrency-password".toCharArray();
KeyStore keyStore = KeyStore.getInstance("PKCS12");
keyStore.load(null, null);
keyStore.setKeyEntry("cert-real-a1", keyPair.getPrivate(), password, new Certificate[]{cert});
Pkcs12CertificateProvider a1Provider = new Pkcs12CertificateProvider();
a1Provider.setKeyStore(keyStore, password);
CertificateManager certManager = new CertificateManager();
certManager.registerProvider(a1Provider);
certManager.discoverCertificates();
ManagedCertificate managedCert = certManager.getAvailableCertificates().get(0);
this.certId = managedCert.id();
SigningService signingService = new SigningService(certManager);
server = new CcspServer(port, certManager, signingService, registry, null);
server.start();
}
@AfterEach
void tearDown() {
if (server != null) {
server.stop();
}
}
@Test
@DisplayName("Deve processar 16 requisições de assinatura concorrentes de aplicações distintas com provedor real sem corrupção")
void shouldHandleConcurrentSigningRequestsSafely() throws Exception {
int threadCount = 8;
int requestCount = 16;
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
CountDownLatch startLatch = new CountDownLatch(1);
List<Future<CcspResponse>> futures = new ArrayList<>();
String[] appIds = {"app-erp", "app-juridico", "app-rh"};
String[] origins = {"http://localhost:3000", "http://localhost:3001", "http://localhost:3002"};
for (int i = 0; i < requestCount; i++) {
final int index = i;
final String appId = appIds[i % appIds.length];
final String origin = origins[i % origins.length];
final String reqId = UUID.randomUUID().toString();
final String nonce = UUID.randomUUID().toString();
Callable<CcspResponse> task = () -> {
// Aguarda largada simultânea
startLatch.await(5, TimeUnit.SECONDS);
byte[] digest = MessageDigest.getInstance("SHA-256")
.digest(("Documento_Concorrente_" + index).getBytes(StandardCharsets.UTF_8));
String digestBase64 = Base64.getEncoder().encodeToString(digest);
CcspRequest request = new CcspRequest(
"1",
reqId,
appId,
"sign",
"SHA256withRSA",
digestBase64,
certId,
Instant.now().plus(60, ChronoUnit.SECONDS).toString(),
System.currentTimeMillis(),
nonce,
null
);
return sendSignRequest(request, origin, appId);
};
futures.add(executor.submit(task));
}
// Dispara todas simultaneamente
startLatch.countDown();
for (Future<CcspResponse> f : futures) {
CcspResponse resp = f.get(10, TimeUnit.SECONDS);
assertThat(resp).isNotNull();
assertThat(resp.status()).isEqualTo("signed");
assertThat(resp.signature()).isNotBlank();
assertThat(resp.algorithm()).isEqualTo("SHA256withRSA");
}
executor.shutdown();
assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue();
}
private CcspResponse sendSignRequest(CcspRequest request, String origin, String appId) throws Exception {
URI uri = URI.create("http://127.0.0.1:" + port + "/ccsp/v1/sign");
HttpURLConnection conn = (HttpURLConnection) uri.toURL().openConnection();
conn.setRequestMethod("POST");
conn.setRequestProperty("Content-Type", "application/json");
conn.setRequestProperty("Origin", origin);
conn.setRequestProperty("X-CyberCore-AppId", appId);
conn.setDoOutput(true);
byte[] payload = mapper.writeValueAsBytes(request);
try (OutputStream os = conn.getOutputStream()) {
os.write(payload);
}
try (InputStream is = conn.getInputStream()) {
return mapper.readValue(is, CcspResponse.class);
}
}
}
@@ -0,0 +1,87 @@
package br.com.cybercore.signer.core.protocol;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class CcspProtocolTest {
private final ObjectMapper objectMapper = new ObjectMapper();
@Test
@DisplayName("Deve serializar e deserializar CcspRequest com sucesso")
void shouldSerializeAndDeserializeRequest() throws Exception {
String json = """
{
"version": "1",
"request_id": "c1f7b640-5e6e-48a5-b1a7-0242ac120002",
"application_id": "app_erp_123",
"operation": "sign",
"algorithm": "SHA256withRSA",
"digest": "47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=",
"certificate_hint": "serial_12345",
"expires_at": "2030-01-01T00:00:00Z",
"timestamp": 1728418000,
"nonce": "test_nonce_987",
"signature_type": "RAW_DIGEST"
}
""";
CcspRequest request = objectMapper.readValue(json, CcspRequest.class);
assertThat(request.version()).isEqualTo("1");
assertThat(request.requestId()).isEqualTo("c1f7b640-5e6e-48a5-b1a7-0242ac120002");
assertThat(request.applicationId()).isEqualTo("app_erp_123");
assertThat(request.operation()).isEqualTo("sign");
assertThat(request.algorithm()).isEqualTo("SHA256withRSA");
assertThat(request.digest()).isEqualTo("47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=");
assertThat(request.isVersionSupported()).isTrue();
assertThat(request.isExpired()).isFalse();
}
@Test
@DisplayName("Deve detectar requisição com expiração ultrapassada")
void shouldDetectExpiredRequest() {
String pastIso = Instant.now().minus(10, ChronoUnit.MINUTES).toString();
CcspRequest request = new CcspRequest(
"1", "uuid", "app", "sign", "SHA256withRSA", "digest", null, pastIso, null, null, null
);
assertThat(request.isExpired()).isTrue();
}
@Test
@DisplayName("Deve serializar CcspResponse de sucesso")
void shouldSerializeSuccessResponse() throws Exception {
CcspResponse response = CcspResponse.success(
"req_001",
"sig_bytes_b64",
"cert_b64",
List.of("cert_b64", "ca_b64"),
"SHA256withRSA"
);
String json = objectMapper.writeValueAsString(response);
assertThat(json).contains("\"status\":\"signed\"");
assertThat(json).contains("\"signature\":\"sig_bytes_b64\"");
assertThat(json).contains("\"certificate\":\"cert_b64\"");
assertThat(json).doesNotContain("\"mock\"");
}
@Test
@DisplayName("Deve serializar CcspResponse de erro estruturado")
void shouldSerializeErrorResponse() throws Exception {
CcspResponse response = CcspResponse.error("req_002", CcspErrorCode.USER_CANCELLED);
String json = objectMapper.writeValueAsString(response);
assertThat(json).contains("\"status\":\"error\"");
assertThat(json).contains("\"error_code\":\"USER_CANCELLED\"");
assertThat(json).contains("Operação cancelada pelo usuário");
}
}
@@ -0,0 +1,323 @@
package br.com.cybercore.signer.core.protocol;
import br.com.cybercore.signer.core.crypto.SigningService;
import br.com.cybercore.signer.core.domain.CertificateStatus;
import br.com.cybercore.signer.core.domain.CertificateType;
import br.com.cybercore.signer.core.domain.ManagedCertificate;
import br.com.cybercore.signer.core.domain.SigningContext;
import br.com.cybercore.signer.core.domain.SigningResult;
import br.com.cybercore.signer.core.provider.CertificateManager;
import br.com.cybercore.signer.core.provider.CertificateProvider;
import br.com.cybercore.signer.core.provider.ProviderType;
import br.com.cybercore.signer.core.security.ApplicationRegistry;
import br.com.cybercore.signer.core.security.AuthorizedApplication;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Base64;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@DisplayName("Testes de Integração do Servidor Local CCSP/1 (CcspServer)")
class CcspServerTest {
private CcspServer server;
private HttpClient httpClient;
private String baseUrl;
@BeforeEach
void setUp(@TempDir Path tempDir) throws Exception {
CertificateManager certManager = new CertificateManager();
SigningService signingService = mock(SigningService.class);
ApplicationRegistry appRegistry = new ApplicationRegistry(tempDir.resolve("apps.json"));
// Registra uma aplicação de teste
appRegistry.register(new AuthorizedApplication(
"app-test",
"App Web Teste",
Set.of("http://localhost:8080", "https://meusistema.com.br"),
true,
Instant.now()
));
// Mock de certificado
ManagedCertificate testCert = new ManagedCertificate(
"cert-1",
"AliasTeste",
CertificateType.A1,
CertificateStatus.AVAILABLE,
"CN=Teste ICP-Brasil, C=BR",
"CN=AC Raiz, C=BR",
"12345678",
"a1b2c3",
"d4e5f6",
Instant.now().minus(10, ChronoUnit.DAYS),
Instant.now().plus(300, ChronoUnit.DAYS),
"SHA256withRSA",
2048,
"Teste ICP-Brasil",
null,
null,
"provider-test",
"Certificado de Teste"
);
CertificateProvider dummyProvider = new CertificateProvider() {
@Override public String getProviderId() { return "provider-test"; }
@Override public String getDisplayName() { return "Provedor Teste"; }
@Override public ProviderType getType() { return ProviderType.A1_FILE; }
@Override public boolean isAvailable() { return true; }
@Override public List<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());
}
};
certManager.registerProvider(dummyProvider);
certManager.discoverCertificates();
when(signingService.sign(any())).thenReturn(
new SigningResult("req-1", new byte[]{10, 20, 30}, "base64-cert", List.of("base64-cert"), "SHA256withRSA", Instant.now())
);
// Instancia o servidor em porta aleatória (0)
server = new CcspServer(0, certManager, signingService, appRegistry, new CcspUserPromptListener() {
@Override
public CompletableFuture<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();
httpClient = HttpClient.newHttpClient();
baseUrl = "http://127.0.0.1:" + server.getPort();
}
@AfterEach
void tearDown() {
if (server != null) {
server.stop();
}
}
@Test
@DisplayName("Endpoint /status deve retornar status ready e versão do agente")
void testGetStatus() throws Exception {
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/ccsp/v1/status"))
.GET()
.build();
HttpResponse<String> resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
assertThat(resp.statusCode()).isEqualTo(200);
assertThat(resp.body()).contains("\"status\":\"ready\"");
assertThat(resp.body()).contains("\"agent\":\"CyberCore Signer\"");
}
@Test
@DisplayName("Endpoint /certificates deve retornar a lista de certificados disponíveis")
void testGetCertificates() throws Exception {
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/ccsp/v1/certificates?application_id=app-test"))
.header("Origin", "https://meusistema.com.br")
.GET()
.build();
HttpResponse<String> resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
assertThat(resp.statusCode()).isEqualTo(200);
assertThat(resp.body()).contains("\"status\":\"success\"");
assertThat(resp.body()).contains("CN=Teste ICP-Brasil, C=BR");
}
@Test
@DisplayName("Requisição OPTIONS deve retornar headers CORS válidos")
void testCorsPreflight() throws Exception {
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/ccsp/v1/sign"))
.method("OPTIONS", HttpRequest.BodyPublishers.noBody())
.header("Origin", "https://meusistema.com.br")
.build();
HttpResponse<Void> resp = httpClient.send(req, HttpResponse.BodyHandlers.discarding());
assertThat(resp.statusCode()).isEqualTo(204);
assertThat(resp.headers().firstValue("Access-Control-Allow-Origin")).hasValue("https://meusistema.com.br");
assertThat(resp.headers().firstValue("Access-Control-Allow-Methods")).hasValue("GET, POST, OPTIONS");
}
@Test
@DisplayName("Endpoint /sign deve assinar digest com sucesso")
void testSignSuccess() throws Exception {
byte[] docHash = MessageDigest.getInstance("SHA-256").digest("Contrato Digital".getBytes());
String hashBase64 = Base64.getEncoder().encodeToString(docHash);
String jsonPayload = """
{
"version": "1",
"request_id": "%s",
"application_id": "app-test",
"operation": "sign",
"algorithm": "SHA256withRSA",
"digest": "%s",
"certificate_hint": "cert-1",
"nonce": "%s"
}
""".formatted(UUID.randomUUID(), hashBase64, UUID.randomUUID());
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/ccsp/v1/sign"))
.header("Content-Type", "application/json")
.header("Origin", "https://meusistema.com.br")
.POST(HttpRequest.BodyPublishers.ofString(jsonPayload))
.build();
HttpResponse<String> resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
assertThat(resp.statusCode()).isEqualTo(200);
assertThat(resp.body()).contains("\"status\":\"signed\"");
assertThat(resp.body()).contains("\"signature\":");
}
@Test
@DisplayName("Endpoint /sign deve prevenir replay attack se mesmo nonce for submetido")
void testReplayPrevention() throws Exception {
byte[] docHash = MessageDigest.getInstance("SHA-256").digest("Documento".getBytes());
String hashBase64 = Base64.getEncoder().encodeToString(docHash);
String fixedNonce = "nonce-repetido-123";
String jsonPayload = """
{
"version": "1",
"request_id": "%s",
"application_id": "app-test",
"operation": "sign",
"algorithm": "SHA256withRSA",
"digest": "%s",
"certificate_hint": "cert-1",
"nonce": "%s"
}
""".formatted(UUID.randomUUID(), hashBase64, fixedNonce);
HttpRequest req1 = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/ccsp/v1/sign"))
.header("Content-Type", "application/json")
.header("Origin", "https://meusistema.com.br")
.POST(HttpRequest.BodyPublishers.ofString(jsonPayload))
.build();
HttpResponse<String> resp1 = httpClient.send(req1, HttpResponse.BodyHandlers.ofString());
assertThat(resp1.statusCode()).isEqualTo(200);
// Segunda tentativa com mesmo nonce deve falhar por REPLAY_DETECTED
String jsonPayload2 = """
{
"version": "1",
"request_id": "%s",
"application_id": "app-test",
"operation": "sign",
"algorithm": "SHA256withRSA",
"digest": "%s",
"certificate_hint": "cert-1",
"nonce": "%s"
}
""".formatted(UUID.randomUUID(), hashBase64, fixedNonce);
HttpRequest req2 = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/ccsp/v1/sign"))
.header("Content-Type", "application/json")
.header("Origin", "https://meusistema.com.br")
.POST(HttpRequest.BodyPublishers.ofString(jsonPayload2))
.build();
HttpResponse<String> resp2 = httpClient.send(req2, HttpResponse.BodyHandlers.ofString());
assertThat(resp2.statusCode()).isEqualTo(400);
assertThat(resp2.body()).contains("REPLAY_DETECTED");
}
@Test
@DisplayName("Deve autenticar usuário via /ccsp/v1/authenticate assinando o desafio do sistema")
void shouldAuthenticateUserWithChallengeSuccessfully() throws Exception {
String jsonPayload = """
{
"version": "1",
"request_id": "%s",
"application_id": "app-test",
"operation": "authenticate",
"challenge": "portal-auth-challenge-%s",
"certificate_hint": "cert-1",
"nonce": "%s"
}
""".formatted(UUID.randomUUID(), System.currentTimeMillis(), UUID.randomUUID());
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/ccsp/v1/authenticate"))
.header("Content-Type", "application/json")
.header("Origin", "https://meusistema.com.br")
.POST(HttpRequest.BodyPublishers.ofString(jsonPayload))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
assertThat(response.statusCode()).isEqualTo(200);
assertThat(response.body()).contains("\"status\":\"authenticated\"");
assertThat(response.body()).contains("\"signature\":\"ChQe\"");
assertThat(response.body()).contains("\"subject_name\":\"Teste ICP-Brasil\"");
}
@Test
@DisplayName("Deve assinar documento PDF preservando metadados de arquivo na resposta")
void shouldSignDocumentWithMetadataPdfSuccessfully() throws Exception {
byte[] hash = MessageDigest.getInstance("SHA-256").digest("PDF_CONTENT_TEST".getBytes());
String hashBase64 = Base64.getEncoder().encodeToString(hash);
String jsonPayload = """
{
"version": "1",
"request_id": "%s",
"application_id": "app-test",
"operation": "sign",
"algorithm": "SHA256withRSA",
"digest": "%s",
"certificate_hint": "cert-1",
"document_name": "proposta_comercial.pdf",
"document_type": "PDF",
"nonce": "%s"
}
""".formatted(UUID.randomUUID(), hashBase64, UUID.randomUUID());
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/ccsp/v1/sign"))
.header("Content-Type", "application/json")
.header("Origin", "https://meusistema.com.br")
.POST(HttpRequest.BodyPublishers.ofString(jsonPayload))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
assertThat(response.statusCode()).isEqualTo(200);
assertThat(response.body()).contains("\"status\":\"signed\"");
assertThat(response.body()).contains("\"document_name\":\"proposta_comercial.pdf\"");
assertThat(response.body()).contains("\"document_type\":\"PDF\"");
}
}
@@ -0,0 +1,66 @@
package br.com.cybercore.signer.core.provider;
import br.com.cybercore.signer.core.domain.CertificateStatus;
import br.com.cybercore.signer.core.domain.CertificateType;
import br.com.cybercore.signer.core.domain.ManagedCertificate;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class CertificateManagerTest {
@Test
@DisplayName("Deve inicializar com provedores padrão")
void shouldCreateDefaultManagerWithProviders() {
CertificateManager manager = CertificateManager.createDefault();
assertThat(manager.getProviders()).isNotEmpty();
assertThat(manager.getProviders())
.extracting(CertificateProvider::getProviderId)
.contains("provider-win-store", "provider-a1", "provider-pkcs11");
}
@Test
@DisplayName("Deve descobrir certificados de múltiplos provedores e indexá-los")
void shouldDiscoverAndIndexCertificates() throws Exception {
CertificateManager manager = new CertificateManager();
CertificateProvider providerA1 = mock(CertificateProvider.class);
when(providerA1.getProviderId()).thenReturn("provider-a1");
when(providerA1.isAvailable()).thenReturn(true);
ManagedCertificate cert1 = new ManagedCertificate(
"cert-1", "Empresa ABC", CertificateType.A1, CertificateStatus.AVAILABLE,
"CN=Empresa ABC", "CN=AC Raiz", "123", "sha1_1", "sha256_1",
Instant.now().minus(1, ChronoUnit.DAYS), Instant.now().plus(300, ChronoUnit.DAYS),
"RSA", 2048, "Empresa ABC", null, "11222333000199", "provider-a1", null
);
when(providerA1.listCertificates()).thenReturn(List.of(cert1));
manager.registerProvider(providerA1);
List<ManagedCertificate> discovered = manager.discoverCertificates();
assertThat(discovered).hasSize(1);
assertThat(discovered.get(0).id()).isEqualTo("cert-1");
assertThat(manager.getProviderForCertificate("cert-1")).isSameAs(providerA1);
// Busca por ID
Optional<ManagedCertificate> byId = manager.findById("cert-1");
assertThat(byId).isPresent();
assertThat(byId.get().commonName()).isEqualTo("Empresa ABC");
// Busca por CNPJ
Optional<ManagedCertificate> byCnpj = manager.findByHint("11222333000199");
assertThat(byCnpj).isPresent();
assertThat(byCnpj.get().id()).isEqualTo("cert-1");
}
}
@@ -0,0 +1,130 @@
package br.com.cybercore.signer.core.provider;
import br.com.cybercore.signer.core.domain.CertificateStatus;
import br.com.cybercore.signer.core.domain.CertificateType;
import br.com.cybercore.signer.core.domain.ManagedCertificate;
import br.com.cybercore.signer.core.domain.SigningContext;
import br.com.cybercore.signer.core.domain.SigningResult;
import br.com.cybercore.signer.core.protocol.CcspErrorCode;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import javax.security.auth.x500.X500Principal;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.KeyStore;
import java.security.MessageDigest;
import java.security.Signature;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Date;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class Pkcs12CertificateProviderTest {
private Pkcs12CertificateProvider provider;
private KeyPair keyPair;
private X509Certificate mockCert;
private KeyStore keyStore;
private static final char[] PASSWORD = "senha-teste".toCharArray();
@BeforeEach
void setUp() throws Exception {
provider = new Pkcs12CertificateProvider();
KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
kpg.initialize(2048);
keyPair = kpg.generateKeyPair();
mockCert = mock(X509Certificate.class);
when(mockCert.getSubjectX500Principal()).thenReturn(new X500Principal("CN=Empresa Teste:12345678000199, O=ICP-Brasil, C=BR"));
when(mockCert.getIssuerX500Principal()).thenReturn(new X500Principal("CN=AC Teste ICP-Brasil, C=BR"));
when(mockCert.getSerialNumber()).thenReturn(BigInteger.valueOf(987654321));
when(mockCert.getNotBefore()).thenReturn(Date.from(Instant.now().minus(10, ChronoUnit.DAYS)));
when(mockCert.getNotAfter()).thenReturn(Date.from(Instant.now().plus(300, ChronoUnit.DAYS)));
when(mockCert.getPublicKey()).thenReturn(keyPair.getPublic());
when(mockCert.getEncoded()).thenReturn(new byte[]{0x30, 0x10, 0x01, 0x02});
keyStore = KeyStore.getInstance("PKCS12");
keyStore.load(null, null);
keyStore.setKeyEntry("alias-a1", keyPair.getPrivate(), PASSWORD, new Certificate[]{mockCert});
}
@Test
@DisplayName("Deve listar certificados carregados no KeyStore PKCS#12")
void shouldListCertificatesFromKeyStore() throws Exception {
provider.setKeyStore(keyStore, PASSWORD);
assertThat(provider.isAvailable()).isTrue();
List<ManagedCertificate> certs = provider.listCertificates();
assertThat(certs).hasSize(1);
ManagedCertificate cert = certs.get(0);
assertThat(cert.alias()).isEqualTo("alias-a1");
assertThat(cert.type()).isEqualTo(CertificateType.A1);
assertThat(cert.status()).isEqualTo(CertificateStatus.AVAILABLE);
assertThat(cert.cnpj()).isEqualTo("12345678000199");
}
@Test
@DisplayName("Deve assinar digest com sucesso utilizando chave RSA do PKCS#12")
void shouldSignDigestSuccessfully() throws Exception {
provider.setKeyStore(keyStore, PASSWORD);
byte[] originalData = "Documento Contratual CyberCore 2026".getBytes(StandardCharsets.UTF_8);
byte[] sha256Digest = MessageDigest.getInstance("SHA-256").digest(originalData);
SigningContext context = new SigningContext(
"req-101",
"app-web-01",
sha256Digest,
"SHA256withRSA",
"alias-a1",
Instant.now()
);
SigningResult result = provider.sign(context);
assertThat(result).isNotNull();
assertThat(result.requestId()).isEqualTo("req-101");
assertThat(result.algorithm()).isEqualTo("SHA256withRSA");
assertThat(result.signatureBytes()).isNotEmpty();
// Validação criptográfica da assinatura gerada
Signature verifier = Signature.getInstance("SHA256withRSA");
verifier.initVerify(keyPair.getPublic());
verifier.update(sha256Digest);
assertThat(verifier.verify(result.signatureBytes())).isTrue();
}
@Test
@DisplayName("Deve falhar ao tentar assinar com algoritmo incompatível ou digest inválido")
void shouldFailOnInvalidDigest() {
provider.setKeyStore(keyStore, PASSWORD);
byte[] invalidDigest = new byte[10]; // Digest menor que os 32 bytes de SHA-256
SigningContext context = new SigningContext(
"req-102",
"app-web-01",
invalidDigest,
"SHA256withRSA",
"alias-a1",
Instant.now()
);
assertThatThrownBy(() -> provider.sign(context))
.isInstanceOf(CertificateProviderException.class)
.hasFieldOrPropertyWithValue("errorCode", CcspErrorCode.SIGNATURE_FAILED);
}
}
@@ -0,0 +1,47 @@
package br.com.cybercore.signer.core.provider;
import br.com.cybercore.signer.core.domain.SigningContext;
import br.com.cybercore.signer.core.protocol.CcspErrorCode;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class WindowsCertificateStoreProviderTest {
@Test
@DisplayName("Deve inicializar o provedor e reportar identificador correto")
void shouldReportCorrectMetadata() {
WindowsCertificateStoreProvider provider = new WindowsCertificateStoreProvider();
assertThat(provider.getProviderId()).isEqualTo("provider-win-store");
assertThat(provider.getDisplayName()).contains("Windows");
assertThat(provider.getType()).isEqualTo(ProviderType.A3_WINDOWS_STORE);
}
@Test
@DisplayName("Em ambiente não-Windows deve tratar graciosamente a indisponibilidade")
void shouldHandleNonWindowsGracefully() throws Exception {
WindowsCertificateStoreProvider provider = new WindowsCertificateStoreProvider();
boolean isWindows = System.getProperty("os.name", "").toLowerCase().contains("win");
if (!isWindows) {
assertThat(provider.isAvailable()).isFalse();
assertThat(provider.listCertificates()).isEmpty();
byte[] digest = new byte[32];
SigningContext context = new SigningContext(
"req_test", "app_1", digest, "SHA256withRSA", "cert_hint", Instant.now()
);
assertThatThrownBy(() -> provider.sign(context))
.isInstanceOf(CertificateProviderException.class)
.hasFieldOrPropertyWithValue("errorCode", CcspErrorCode.PROVIDER_NOT_AVAILABLE);
}
}
}
@@ -0,0 +1,47 @@
package br.com.cybercore.signer.core.security;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Path;
import java.time.Instant;
import java.util.Optional;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("Testes do Registro de Aplicações Autorizadas (ApplicationRegistry)")
class ApplicationRegistryTest {
@Test
@DisplayName("Deve registrar, consultar e persistir aplicação com origens permitidas")
void testRegisterAndPersist(@TempDir Path tempDir) {
Path jsonPath = tempDir.resolve("test-apps.json");
ApplicationRegistry registry = new ApplicationRegistry(jsonPath);
AuthorizedApplication app = new AuthorizedApplication(
"app-123",
"Portal ERP Corporativo",
Set.of("https://erp.empresa.com.br", "http://localhost:3000"),
true,
Instant.now()
);
registry.register(app);
assertThat(registry.findById("app-123")).isPresent();
assertThat(registry.isAuthorized("app-123", "https://erp.empresa.com.br")).isTrue();
assertThat(registry.isAuthorized("app-123", "https://malicious-site.com")).isFalse();
// Novo registry no mesmo arquivo para validar persistência
ApplicationRegistry reloaded = new ApplicationRegistry(jsonPath);
Optional<AuthorizedApplication> loaded = reloaded.findById("app-123");
assertThat(loaded).isPresent();
assertThat(loaded.get().name()).isEqualTo("Portal ERP Corporativo");
// Revogação
reloaded.revoke("app-123");
assertThat(reloaded.findById("app-123")).isEmpty();
}
}
@@ -0,0 +1,31 @@
package br.com.cybercore.signer.core.security;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.time.Duration;
import static org.assertj.core.api.Assertions.assertThat;
class NonceManagerTest {
@Test
@DisplayName("Deve aceitar primeiro registro e rejeitar repetição (replay)")
void shouldAcceptOnceAndRejectDuplicate() {
NonceManager manager = new NonceManager(Duration.ofMinutes(1));
boolean first = manager.registerNonce("nonce_abc_123");
boolean second = manager.registerNonce("nonce_abc_123");
assertThat(first).isTrue();
assertThat(second).isFalse();
}
@Test
@DisplayName("Deve rejeitar nonce nulo ou vazio")
void shouldRejectInvalidNonce() {
NonceManager manager = new NonceManager();
assertThat(manager.registerNonce(null)).isFalse();
assertThat(manager.registerNonce(" ")).isFalse();
}
}
@@ -0,0 +1,46 @@
package br.com.cybercore.signer.core.security;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
class OriginValidatorTest {
private final OriginValidator validator = new OriginValidator();
@Test
@DisplayName("Deve permitir origem exatamente autorizada")
void shouldAllowAuthorizedOrigin() {
AuthorizedApplication app = new AuthorizedApplication(
"app_1", "Portal ERP", Set.of("https://erp.empresa.com.br", "http://localhost:3000"), true, null
);
assertThat(validator.isAllowed("https://erp.empresa.com.br", app)).isTrue();
assertThat(validator.isAllowed("http://localhost:3000", app)).isTrue();
}
@Test
@DisplayName("Deve rejeitar origens não cadastradas ou fraudulentas")
void shouldRejectUnauthorizedOrigin() {
AuthorizedApplication app = new AuthorizedApplication(
"app_1", "Portal ERP", Set.of("https://erp.empresa.com.br"), true, null
);
assertThat(validator.isAllowed("https://malicious-site.com", app)).isFalse();
assertThat(validator.isAllowed("https://erp.empresa.com.br.attacker.com", app)).isFalse();
assertThat(validator.isAllowed(null, app)).isFalse();
}
@Test
@DisplayName("Deve rejeitar qualquer origem se a aplicação estiver desabilitada")
void shouldRejectWhenApplicationIsDisabled() {
AuthorizedApplication app = new AuthorizedApplication(
"app_1", "Portal ERP", Set.of("https://erp.empresa.com.br"), false, null
);
assertThat(validator.isAllowed("https://erp.empresa.com.br", app)).isFalse();
}
}
@@ -0,0 +1,61 @@
package br.com.cybercore.signer.core.security;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class PinSessionManagerTest {
private PinSessionManager manager;
@BeforeEach
void setUp() {
manager = new PinSessionManager();
manager.clearAll();
}
@Test
@DisplayName("Deve salvar e recuperar PIN de sessão para um certificado")
void shouldSaveAndRetrievePin() {
char[] pin = "1234".toCharArray();
manager.savePin("cert-1", pin);
assertThat(manager.hasPin("cert-1")).isTrue();
assertThat(manager.getPin("cert-1")).isEqualTo("1234".toCharArray());
}
@Test
@DisplayName("Deve limpar PIN e sobrescrever array com espaços")
void shouldClearPinAndWipeMemory() {
char[] pin = "5678".toCharArray();
manager.savePin("cert-2", pin);
manager.clearPin("cert-2");
assertThat(manager.hasPin("cert-2")).isFalse();
assertThat(manager.getPin("cert-2")).isNull();
}
@Test
@DisplayName("Quando alwaysPromptPin estiver ativo, não deve retornar PIN salvo")
void shouldIgnoreStoredPinWhenAlwaysPromptIsActive() {
manager.savePin("cert-3", "9999".toCharArray());
manager.setAlwaysPromptPin(true);
assertThat(manager.hasPin("cert-3")).isFalse();
assertThat(manager.getPin("cert-3")).isNull();
}
@Test
@DisplayName("Deve limpar todos os PINs em memória")
void shouldClearAllPins() {
manager.savePin("cert-a", "1111".toCharArray());
manager.savePin("cert-b", "2222".toCharArray());
manager.clearAll();
assertThat(manager.hasPin("cert-a")).isFalse();
assertThat(manager.hasPin("cert-b")).isFalse();
}
}
@@ -0,0 +1,18 @@
package br.com.cybercore.signer.core.security;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
@DisplayName("Testes de Gerenciamento de Sessão de Smart Card (SmartCardSessionManager)")
class SmartCardSessionManagerTest {
@Test
@DisplayName("Deve executar resetCardSessions sem lançar exceções mesmo se o hardware não estiver presente")
void testResetCardSessionsSafeExecution() {
assertDoesNotThrow(() -> {
SmartCardSessionManager.resetCardSessions();
});
}
}
@@ -0,0 +1,51 @@
package br.com.cybercore.signer.core.security;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.concurrent.atomic.AtomicBoolean;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("Testes de Monitoramento Dinâmico de Smart Cards (SmartCardWatcher)")
class SmartCardWatcherTest {
@Test
@DisplayName("Deve gerenciar ciclo de vida (start/stop) sem exceção")
void shouldManageLifecycleGracefully() {
SmartCardWatcher watcher = new SmartCardWatcher();
assertThat(watcher.isRunning()).isFalse();
watcher.start();
assertThat(watcher.isRunning()).isTrue();
watcher.stop();
assertThat(watcher.isRunning()).isFalse();
}
@Test
@DisplayName("Deve adicionar e remover listeners corretamente")
void shouldAddAndRemoveListeners() {
SmartCardWatcher watcher = new SmartCardWatcher();
AtomicBoolean inserted = new AtomicBoolean(false);
AtomicBoolean removed = new AtomicBoolean(false);
SmartCardWatcher.CardEventListener listener = new SmartCardWatcher.CardEventListener() {
@Override
public void onCardInserted(String terminalName) {
inserted.set(true);
}
@Override
public void onCardRemoved(String terminalName) {
removed.set(true);
}
};
watcher.addListener(listener);
watcher.removeListener(listener);
// Não deve falhar nem disparar listeners removidos
assertThat(inserted.get()).isFalse();
assertThat(removed.get()).isFalse();
}
}
@@ -0,0 +1,183 @@
package br.com.cybercore.signer.core.validation;
import br.com.cybercore.signer.core.domain.ManagedCertificate;
import br.com.cybercore.signer.core.pdf.PdfSignatureOptions;
import br.com.cybercore.signer.core.pdf.PdfSigningService;
import br.com.cybercore.signer.core.provider.CertificateManager;
import br.com.cybercore.signer.core.provider.Pkcs12CertificateProvider;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.cert.X509CertificateHolder;
import org.bouncycastle.cert.X509v3CertificateBuilder;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
import org.bouncycastle.cms.CMSProcessableByteArray;
import org.bouncycastle.cms.CMSSignedData;
import org.bouncycastle.cms.CMSSignedDataGenerator;
import org.bouncycastle.cms.jcajce.JcaSignerInfoGeneratorBuilder;
import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.bouncycastle.operator.jcajce.JcaDigestCalculatorProviderBuilder;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayOutputStream;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.KeyStore;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import java.util.Date;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("Testes do Serviço de Validação de Assinaturas (SignatureValidationService)")
class SignatureValidationServiceTest {
private SignatureValidationService validationService;
private KeyPair keyPair;
private X509Certificate certificate;
private CertificateManager certificateManager;
private String testCertId;
@BeforeEach
void setUp() throws Exception {
validationService = new SignatureValidationService();
// 1. Gera par de chaves e certificado X.509 em memória
KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
kpg.initialize(2048);
keyPair = kpg.generateKeyPair();
long now = System.currentTimeMillis();
Date notBefore = new Date(now - 10000);
Date notAfter = new Date(now + 365L * 24 * 3600 * 1000);
X500Name subject = new X500Name("CN=Advogado Teste da Silva:98765432100, O=CyberCore, C=BR");
X509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder(
subject,
BigInteger.valueOf(now),
notBefore,
notAfter,
subject,
keyPair.getPublic()
);
ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").build(keyPair.getPrivate());
certificate = new JcaX509CertificateConverter().getCertificate(certBuilder.build(signer));
// 2. Configura KeyStore em memória e CertificateManager
KeyStore ks = KeyStore.getInstance("PKCS12");
ks.load(null, null);
ks.setKeyEntry("test-alias", keyPair.getPrivate(), "secret".toCharArray(), new Certificate[]{certificate});
Pkcs12CertificateProvider provider = new Pkcs12CertificateProvider("test-provider", "Provedor de Teste");
provider.setKeyStore(ks, "secret".toCharArray());
certificateManager = new CertificateManager();
certificateManager.registerProvider(provider);
List<ManagedCertificate> certs = certificateManager.discoverCertificates();
testCertId = certs.get(0).id();
}
@Test
@DisplayName("Deve validar com sucesso uma assinatura CMS destacada (Detached)")
void shouldValidateDetachedSignatureSuccessfully() throws Exception {
byte[] docBytes = "Conteúdo importante do contrato digital CyberCore.".getBytes(StandardCharsets.UTF_8);
// Gera assinatura CMS destacada
CMSSignedDataGenerator gen = new CMSSignedDataGenerator();
ContentSigner sha256Signer = new JcaContentSignerBuilder("SHA256withRSA").build(keyPair.getPrivate());
gen.addSignerInfoGenerator(new JcaSignerInfoGeneratorBuilder(
new JcaDigestCalculatorProviderBuilder().build()
).build(sha256Signer, certificate));
gen.addCertificate(new X509CertificateHolder(certificate.getEncoded()));
CMSSignedData signedData = gen.generate(new CMSProcessableByteArray(docBytes), false);
byte[] signatureBytes = signedData.getEncoded();
ValidationResult result = validationService.validateDetached(docBytes, signatureBytes, "contrato.txt");
assertThat(result.isValid()).isTrue();
assertThat(result.status()).isEqualTo(ValidationStatus.VALID);
assertThat(result.type()).isEqualTo(ValidationType.DETACHED_CMS);
assertThat(result.signers()).hasSize(1);
assertThat(result.signers().get(0).signerName()).contains("Advogado Teste da Silva");
assertThat(result.signers().get(0).digestValid()).isTrue();
assertThat(result.signers().get(0).signatureValid()).isTrue();
}
@Test
@DisplayName("Deve acusar falha de validação quando o documento original foi alterado (adulteração)")
void shouldFailWhenDocumentHasBeenTampered() throws Exception {
byte[] originalBytes = "Documento original intocado.".getBytes(StandardCharsets.UTF_8);
byte[] tamperedBytes = "Documento adulterado maliciosamente.".getBytes(StandardCharsets.UTF_8);
// Gera assinatura do original
CMSSignedDataGenerator gen = new CMSSignedDataGenerator();
ContentSigner sha256Signer = new JcaContentSignerBuilder("SHA256withRSA").build(keyPair.getPrivate());
gen.addSignerInfoGenerator(new JcaSignerInfoGeneratorBuilder(
new JcaDigestCalculatorProviderBuilder().build()
).build(sha256Signer, certificate));
gen.addCertificate(new X509CertificateHolder(certificate.getEncoded()));
CMSSignedData signedData = gen.generate(new CMSProcessableByteArray(originalBytes), false);
byte[] signatureBytes = signedData.getEncoded();
// Valida passando o documento adulterado
ValidationResult result = validationService.validateDetached(tamperedBytes, signatureBytes, "doc.txt");
assertThat(result.isValid()).isFalse();
assertThat(result.status()).isEqualTo(ValidationStatus.INVALID);
}
@Test
@DisplayName("Deve validar com sucesso um documento PDF assinado (PAdES Incorporado)")
void shouldValidateSignedPdfSuccessfully() throws Exception {
// Cria PDF simples em memória
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage());
doc.save(baos);
}
byte[] rawPdf = baos.toByteArray();
// Assina o PDF via PdfSigningService
PdfSigningService signingService = new PdfSigningService(certificateManager);
PdfSignatureOptions options = PdfSignatureOptions.builder(testCertId)
.visibleSeal(true)
.reason("Assinatura de teste unitário")
.build();
byte[] signedPdf = signingService.signPdf(rawPdf, options);
// Valida como assinatura incorporada
ValidationResult result = validationService.validateAttached(signedPdf, "documento_assinado.pdf");
assertThat(result.isValid()).isTrue();
assertThat(result.type()).isEqualTo(ValidationType.PADES_PDF);
assertThat(result.signers()).isNotEmpty();
assertThat(result.signers().get(0).signatureValid()).isTrue();
}
@Test
@DisplayName("Deve acusar inválido para PDF que não possui assinaturas")
void shouldFailForUnsignedPdf() throws Exception {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage());
doc.save(baos);
}
ValidationResult result = validationService.validateAttached(baos.toByteArray(), "em_branco.pdf");
assertThat(result.isValid()).isFalse();
assertThat(result.type()).isEqualTo(ValidationType.PADES_PDF);
assertThat(result.summaryMessage()).contains("Nenhuma assinatura digital encontrada");
}
}
@@ -0,0 +1,152 @@
package br.com.cybercore.signer.core.xml;
import br.com.cybercore.signer.core.domain.ManagedCertificate;
import br.com.cybercore.signer.core.provider.CertificateManager;
import br.com.cybercore.signer.core.provider.Pkcs12CertificateProvider;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.cert.X509v3CertificateBuilder;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.KeyStore;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import java.util.Date;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class XmlSigningServiceTest {
private CertificateManager certificateManager;
private XmlSigningService xmlSigningService;
private String testCertId;
@BeforeEach
void setUp() throws Exception {
certificateManager = new CertificateManager();
KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
kpg.initialize(2048);
KeyPair kp = kpg.generateKeyPair();
long now = System.currentTimeMillis();
Date notBefore = new Date(now - 100000);
Date notAfter = new Date(now + 365L * 24 * 3600 * 1000);
X500Name subject = new X500Name("CN=Empresa Teste LTDA:98765432000199, O=CyberCore XML, C=BR");
X509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder(
subject,
BigInteger.valueOf(System.currentTimeMillis()),
notBefore,
notAfter,
subject,
kp.getPublic()
);
ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").build(kp.getPrivate());
X509Certificate cert = new JcaX509CertificateConverter().getCertificate(certBuilder.build(signer));
KeyStore ks = KeyStore.getInstance("PKCS12");
ks.load(null, null);
char[] pwd = "xmlpassword".toCharArray();
ks.setKeyEntry("xml-cert", kp.getPrivate(), pwd, new Certificate[]{cert});
Pkcs12CertificateProvider provider = new Pkcs12CertificateProvider("xml-provider", "Provedor XML Teste");
provider.setKeyStore(ks, pwd);
certificateManager.registerProvider(provider);
List<ManagedCertificate> list = certificateManager.discoverCertificates();
this.testCertId = list.get(0).id();
this.xmlSigningService = new XmlSigningService(certificateManager);
}
@Test
@DisplayName("Deve assinar documento XML completo com XMLDSig Enveloped e validar com sucesso")
void shouldSignWholeXmlDocumentAndValidate() throws Exception {
String xml = """
<?xml version="1.0" encoding="UTF-8"?>
<pedido id="ped-1001">
<cliente>Fulano de Tal</cliente>
<valor>1500.00</valor>
</pedido>
""";
XmlSignatureOptions options = XmlSignatureOptions.wholeDocument(testCertId, null);
byte[] signedXmlBytes = xmlSigningService.signXml(xml.getBytes(StandardCharsets.UTF_8), options);
String signedXmlStr = new String(signedXmlBytes, StandardCharsets.UTF_8);
assertThat(signedXmlStr).contains("<Signature");
assertThat(signedXmlStr).contains("<SignedInfo");
assertThat(signedXmlStr).contains("<SignatureValue");
assertThat(signedXmlStr).contains("<KeyInfo");
// Validação criptográfica
boolean isValid = xmlSigningService.validateXmlSignature(signedXmlBytes);
assertThat(isValid).isTrue();
}
@Test
@DisplayName("Deve assinar nó XML específico com atributo Id (estilo NF-e) e validar")
void shouldSignSpecificXmlElementById() throws Exception {
String nfeXml = """
<?xml version="1.0" encoding="UTF-8"?>
<NFe xmlns="http://www.portalfiscal.inf.br/nfe">
<infNFe Id="NFe35261012345678000195550010000000011000000010">
<ide>
<nNF>1</nNF>
</ide>
<total>
<vNF>250.00</vNF>
</total>
</infNFe>
</NFe>
""";
XmlSignatureOptions options = XmlSignatureOptions.forElement(
testCertId,
null,
"infNFe",
"NFe35261012345678000195550010000000011000000010"
);
byte[] signedNfe = xmlSigningService.signXml(nfeXml.getBytes(StandardCharsets.UTF_8), options);
String signedStr = new String(signedNfe, StandardCharsets.UTF_8);
assertThat(signedStr).contains("URI=\"#NFe35261012345678000195550010000000011000000010\"");
boolean isValid = xmlSigningService.validateXmlSignature(signedNfe);
assertThat(isValid).isTrue();
}
@Test
@DisplayName("Deve detectar adulteração em XML assinado e reprovar na validação")
void shouldDetectTamperingInSignedXml() throws Exception {
String xml = """
<?xml version="1.0" encoding="UTF-8"?>
<contrato>
<clausula>Pagamento de R$ 100,00</clausula>
</contrato>
""";
XmlSignatureOptions options = XmlSignatureOptions.wholeDocument(testCertId, null);
byte[] signedXml = xmlSigningService.signXml(xml.getBytes(StandardCharsets.UTF_8), options);
// Adulteração intencional do conteúdo original
String tampered = new String(signedXml, StandardCharsets.UTF_8)
.replace("R$ 100,00", "R$ 999.999,00");
boolean isValid = xmlSigningService.validateXmlSignature(tampered.getBytes(StandardCharsets.UTF_8));
assertThat(isValid).isFalse();
}
}