feat: initial commit - cybercore signer desktop agent
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>br.com.cybercore.signer</groupId>
|
||||
<artifactId>cybercore-signer-parent</artifactId>
|
||||
<version>0.1.0-SNAPSHOT</version>
|
||||
<relativePath>../../pom.xml</relativePath>
|
||||
</parent>
|
||||
|
||||
<artifactId>cybercore-core</artifactId>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>CyberCore Signer :: Core</name>
|
||||
<description>Núcleo de domínio, abstrações criptográficas, provedores A1/A3, protocolo CCSP e servidor local.</description>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-api</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.datatype</groupId>
|
||||
<artifactId>jackson-datatype-jsr310</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- PDF (PAdES) e Criptografia CMS/PKCS#7 -->
|
||||
<dependency>
|
||||
<groupId>org.apache.pdfbox</groupId>
|
||||
<artifactId>pdfbox</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.bouncycastle</groupId>
|
||||
<artifactId>bcpkix-jdk18on</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.bouncycastle</groupId>
|
||||
<artifactId>bcprov-jdk18on</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.bouncycastle</groupId>
|
||||
<artifactId>bcutil-jdk18on</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Testes -->
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>ch.qos.logback</groupId>
|
||||
<artifactId>logback-classic</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
+43
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
+94
@@ -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;
|
||||
}
|
||||
}
|
||||
+66
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+169
@@ -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;
|
||||
}
|
||||
}
|
||||
+230
@@ -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();
|
||||
}
|
||||
}
|
||||
+51
@@ -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);
|
||||
}
|
||||
}
|
||||
+87
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
@@ -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;
|
||||
}
|
||||
}
|
||||
+32
@@ -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;
|
||||
}
|
||||
}
|
||||
+56
@@ -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();
|
||||
}
|
||||
}
|
||||
+55
@@ -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;
|
||||
}
|
||||
}
|
||||
+38
@@ -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);
|
||||
}
|
||||
}
|
||||
+63
@@ -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;
|
||||
}
|
||||
}
|
||||
+20
@@ -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;
|
||||
}
|
||||
}
|
||||
+22
@@ -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;
|
||||
}
|
||||
}
|
||||
+186
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+351
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+64
@@ -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);
|
||||
}
|
||||
}
|
||||
+175
@@ -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._-]", "_");
|
||||
}
|
||||
}
|
||||
+64
@@ -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);
|
||||
}
|
||||
}
|
||||
+411
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
package br.com.cybercore.signer.core.pdf.seal;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Modelo de configuração e identidade visual de um Tema de Selo de Assinatura PDF.
|
||||
* Permite definir paleta de cores, dimensões, templates, textos personalizados e preferências de exibição.
|
||||
*/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record PdfSealTheme(
|
||||
String id,
|
||||
String name,
|
||||
String description,
|
||||
int width,
|
||||
int height,
|
||||
String primaryColor,
|
||||
String secondaryColor,
|
||||
String backgroundColor,
|
||||
String borderColor,
|
||||
String textColor,
|
||||
String svgTemplate,
|
||||
String customHeader,
|
||||
String customFooter,
|
||||
String iconStyle,
|
||||
CpfDisplayMode defaultCpfMode,
|
||||
boolean defaultIncludeDateTime,
|
||||
boolean isCustom,
|
||||
String customLogoBase64,
|
||||
String customFieldLabel,
|
||||
String customFieldValue
|
||||
) {
|
||||
|
||||
public PdfSealTheme {
|
||||
Objects.requireNonNull(id, "ID do tema não pode ser nulo.");
|
||||
Objects.requireNonNull(name, "Nome do tema não pode ser nulo.");
|
||||
if (width <= 0) width = 320;
|
||||
if (height <= 0) height = 82;
|
||||
if (primaryColor == null) primaryColor = "#0d47a1";
|
||||
if (secondaryColor == null) secondaryColor = "#0284c7";
|
||||
if (backgroundColor == null) backgroundColor = "#ffffff";
|
||||
if (borderColor == null) borderColor = "#cbd5e1";
|
||||
if (textColor == null) textColor = "#0f172a";
|
||||
if (iconStyle == null || iconStyle.isBlank()) iconStyle = "shield";
|
||||
if (defaultCpfMode == null) defaultCpfMode = CpfDisplayMode.FULL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construtor para temas customizados com logotipo Base64, sem campos extras.
|
||||
*/
|
||||
public PdfSealTheme(String id, String name, String description, int width, int height,
|
||||
String primaryColor, String secondaryColor, String backgroundColor,
|
||||
String borderColor, String textColor, String svgTemplate,
|
||||
String customHeader, String customFooter, String iconStyle,
|
||||
CpfDisplayMode defaultCpfMode, boolean defaultIncludeDateTime, boolean isCustom,
|
||||
String customLogoBase64) {
|
||||
this(id, name, description, width, height, primaryColor, secondaryColor, backgroundColor,
|
||||
borderColor, textColor, svgTemplate, customHeader, customFooter, iconStyle,
|
||||
defaultCpfMode, defaultIncludeDateTime, isCustom, customLogoBase64, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Construtor para temas customizados sem logo personalizada em imagem.
|
||||
*/
|
||||
public PdfSealTheme(String id, String name, String description, int width, int height,
|
||||
String primaryColor, String secondaryColor, String backgroundColor,
|
||||
String borderColor, String textColor, String svgTemplate,
|
||||
String customHeader, String customFooter, String iconStyle,
|
||||
CpfDisplayMode defaultCpfMode, boolean defaultIncludeDateTime, boolean isCustom) {
|
||||
this(id, name, description, width, height, primaryColor, secondaryColor, backgroundColor,
|
||||
borderColor, textColor, svgTemplate, customHeader, customFooter, iconStyle,
|
||||
defaultCpfMode, defaultIncludeDateTime, isCustom, null, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Construtor legado para os temas embutidos do sistema.
|
||||
*/
|
||||
public PdfSealTheme(String id, String name, String description, int width, int height,
|
||||
String primaryColor, String secondaryColor, String backgroundColor,
|
||||
String borderColor, String textColor, String svgTemplate) {
|
||||
this(id, name, description, width, height, primaryColor, secondaryColor, backgroundColor,
|
||||
borderColor, textColor, svgTemplate, null, null, "shield", CpfDisplayMode.FULL, true, false, null, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formata o campo customizado (ex: "Matrícula: 104829" ou "Cargo: Auditor Fiscal").
|
||||
*/
|
||||
public String formatCustomField() {
|
||||
boolean hasLabel = customFieldLabel != null && !customFieldLabel.isBlank();
|
||||
boolean hasValue = customFieldValue != null && !customFieldValue.isBlank();
|
||||
if (hasLabel && hasValue) {
|
||||
String label = customFieldLabel.trim();
|
||||
if (!label.endsWith(":")) label += ":";
|
||||
return label + " " + customFieldValue.trim();
|
||||
} else if (hasValue) {
|
||||
return customFieldValue.trim();
|
||||
} else if (hasLabel) {
|
||||
return customFieldLabel.trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Gera o código SVG final substituindo os marcadores pelos dados reais da assinatura.
|
||||
*/
|
||||
public String renderSvg(PdfSealData data) {
|
||||
if (svgTemplate == null || svgTemplate.isBlank()) {
|
||||
return "";
|
||||
}
|
||||
return svgTemplate
|
||||
.replace("${THEME_PRIMARY}", primaryColor)
|
||||
.replace("${THEME_SECONDARY}", secondaryColor)
|
||||
.replace("${THEME_BG}", backgroundColor)
|
||||
.replace("${THEME_BORDER}", borderColor)
|
||||
.replace("${THEME_TEXT}", textColor)
|
||||
.replace("${SIGNER_NAME}", escapeXml(data.signerName()))
|
||||
.replace("${SIGNER_DOC}", escapeXml(data.identifier()))
|
||||
.replace("${SIGNING_DATE}", escapeXml(data.signingDate()))
|
||||
.replace("${CERT_ISSUER}", escapeXml(data.issuer()))
|
||||
.replace("${HASH_SUMMARY}", escapeXml(data.hashSummary()))
|
||||
.replace("${REASON}", escapeXml(data.reason()))
|
||||
.replace("${LOCATION}", escapeXml(data.location()));
|
||||
}
|
||||
|
||||
private static String escapeXml(String text) {
|
||||
if (text == null) return "";
|
||||
return text
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
.replace("'", "'");
|
||||
}
|
||||
}
|
||||
+240
@@ -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;
|
||||
}
|
||||
}
|
||||
+35
@@ -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;
|
||||
}
|
||||
}
|
||||
+39
@@ -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);
|
||||
}
|
||||
}
|
||||
+96
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+136
@@ -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());
|
||||
}
|
||||
}
|
||||
+550
@@ -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);
|
||||
}
|
||||
}
|
||||
+31
@@ -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);
|
||||
}
|
||||
+194
@@ -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();
|
||||
}
|
||||
}
|
||||
+61
@@ -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
|
||||
}
|
||||
}
|
||||
+32
@@ -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;
|
||||
}
|
||||
}
|
||||
+247
@@ -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;
|
||||
}
|
||||
}
|
||||
+318
@@ -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;
|
||||
}
|
||||
}
|
||||
+21
@@ -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;
|
||||
}
|
||||
}
|
||||
+307
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+162
@@ -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();
|
||||
}
|
||||
}
|
||||
+44
@@ -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;
|
||||
}
|
||||
}
|
||||
+53
@@ -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();
|
||||
}
|
||||
}
|
||||
+44
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+106
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+69
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+197
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+417
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
@@ -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;
|
||||
}
|
||||
}
|
||||
+20
@@ -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;
|
||||
}
|
||||
}
|
||||
+21
@@ -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;
|
||||
}
|
||||
}
|
||||
+28
@@ -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;
|
||||
}
|
||||
}
|
||||
+32
@@ -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);
|
||||
}
|
||||
}
|
||||
+290
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
+47
@@ -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();
|
||||
}
|
||||
}
|
||||
+45
@@ -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);
|
||||
}
|
||||
}
|
||||
+147
@@ -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);
|
||||
}
|
||||
}
|
||||
+22
@@ -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");
|
||||
}
|
||||
}
|
||||
+218
@@ -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();
|
||||
}
|
||||
}
|
||||
+31
@@ -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();
|
||||
}
|
||||
}
|
||||
+137
@@ -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");
|
||||
}
|
||||
}
|
||||
+140
@@ -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();
|
||||
}
|
||||
}
|
||||
+198
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+87
@@ -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");
|
||||
}
|
||||
}
|
||||
+323
@@ -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\"");
|
||||
}
|
||||
}
|
||||
+66
@@ -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");
|
||||
}
|
||||
}
|
||||
+130
@@ -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);
|
||||
}
|
||||
}
|
||||
+47
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+47
@@ -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();
|
||||
}
|
||||
}
|
||||
+31
@@ -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();
|
||||
}
|
||||
}
|
||||
+46
@@ -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();
|
||||
}
|
||||
}
|
||||
+61
@@ -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();
|
||||
}
|
||||
}
|
||||
+18
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
+51
@@ -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();
|
||||
}
|
||||
}
|
||||
+183
@@ -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");
|
||||
}
|
||||
}
|
||||
+152
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user