feat: initial commit - cybercore signer desktop agent
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
<?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-app</artifactId>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>CyberCore Signer :: Application</name>
|
||||
<description>Módulo executável desktop, ciclo de vida da aplicação, shutdown hooks e empacotamento nativo.</description>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>br.com.cybercore.signer</groupId>
|
||||
<artifactId>cybercore-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>br.com.cybercore.signer</groupId>
|
||||
<artifactId>cybercore-ui</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- JavaFX Neutro -->
|
||||
<dependency>
|
||||
<groupId>org.openjfx</groupId>
|
||||
<artifactId>javafx-controls</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.openjfx</groupId>
|
||||
<artifactId>javafx-graphics</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.openjfx</groupId>
|
||||
<artifactId>javafx-base</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- JavaFX Nativos Windows (para execução direta em ambiente Windows) -->
|
||||
<dependency>
|
||||
<groupId>org.openjfx</groupId>
|
||||
<artifactId>javafx-controls</artifactId>
|
||||
<version>${javafx.version}</version>
|
||||
<classifier>win</classifier>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.openjfx</groupId>
|
||||
<artifactId>javafx-graphics</artifactId>
|
||||
<version>${javafx.version}</version>
|
||||
<classifier>win</classifier>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.openjfx</groupId>
|
||||
<artifactId>javafx-base</artifactId>
|
||||
<version>${javafx.version}</version>
|
||||
<classifier>win</classifier>
|
||||
</dependency>
|
||||
|
||||
<!-- JavaFX Nativos Linux (para execução direta em ambiente Linux) -->
|
||||
<dependency>
|
||||
<groupId>org.openjfx</groupId>
|
||||
<artifactId>javafx-controls</artifactId>
|
||||
<version>${javafx.version}</version>
|
||||
<classifier>linux</classifier>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.openjfx</groupId>
|
||||
<artifactId>javafx-graphics</artifactId>
|
||||
<version>${javafx.version}</version>
|
||||
<classifier>linux</classifier>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.openjfx</groupId>
|
||||
<artifactId>javafx-base</artifactId>
|
||||
<version>${javafx.version}</version>
|
||||
<classifier>linux</classifier>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-api</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>ch.qos.logback</groupId>
|
||||
<artifactId>logback-classic</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>ch.qos.logback</groupId>
|
||||
<artifactId>logback-core</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Testes de Arquitetura -->
|
||||
<dependency>
|
||||
<groupId>com.tngtech.archunit</groupId>
|
||||
<artifactId>archunit-junit5</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.openjfx</groupId>
|
||||
<artifactId>javafx-maven-plugin</artifactId>
|
||||
<configuration>
|
||||
<mainClass>br.com.cybercore.signer.app.CyberCoreSignerApplication</mainClass>
|
||||
</configuration>
|
||||
</plugin>
|
||||
|
||||
<!-- Geração de Fat JAR executável autocontido -->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-shade-plugin</artifactId>
|
||||
<version>3.6.0</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<phase>package</phase>
|
||||
<goals>
|
||||
<goal>shade</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<finalName>cybercore-signer</finalName>
|
||||
<shadedArtifactAttached>false</shadedArtifactAttached>
|
||||
<transformers>
|
||||
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
||||
<mainClass>br.com.cybercore.signer.app.CyberCoreSignerApplication</mainClass>
|
||||
<manifestEntries>
|
||||
<Implementation-Title>CyberCore Signer</Implementation-Title>
|
||||
<Implementation-Version>${project.version}</Implementation-Version>
|
||||
<Implementation-Vendor>CyberCore</Implementation-Vendor>
|
||||
</manifestEntries>
|
||||
</transformer>
|
||||
</transformers>
|
||||
<filters>
|
||||
<filter>
|
||||
<artifact>*:*</artifact>
|
||||
<excludes>
|
||||
<exclude>META-INF/*.SF</exclude>
|
||||
<exclude>META-INF/*.DSA</exclude>
|
||||
<exclude>META-INF/*.RSA</exclude>
|
||||
<exclude>module-info.class</exclude>
|
||||
<exclude>META-INF/versions/**/module-info.class</exclude>
|
||||
</excludes>
|
||||
</filter>
|
||||
</filters>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
package br.com.cybercore.signer.app;
|
||||
|
||||
import br.com.cybercore.signer.core.diagnostics.DiagnosticReport;
|
||||
import br.com.cybercore.signer.core.diagnostics.DiagnosticService;
|
||||
import br.com.cybercore.signer.core.crypto.SigningService;
|
||||
import br.com.cybercore.signer.core.protocol.CcspServer;
|
||||
import br.com.cybercore.signer.core.provider.CertificateManager;
|
||||
import br.com.cybercore.signer.ui.CyberCoreDesktopApplication;
|
||||
import javafx.application.Application;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.awt.GraphicsEnvironment;
|
||||
|
||||
/**
|
||||
* Ponto de entrada (Main Class) e bootstrap do agente CyberCore Signer.
|
||||
*/
|
||||
public class CyberCoreSignerApplication {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(CyberCoreSignerApplication.class);
|
||||
|
||||
private static boolean developmentMode = false;
|
||||
private static boolean headlessMode = false;
|
||||
private static boolean noWaitMode = false;
|
||||
private static volatile CcspServer runningServer = null;
|
||||
|
||||
public static CcspServer getRunningServer() {
|
||||
return runningServer;
|
||||
}
|
||||
|
||||
public static void stopServer() {
|
||||
if (runningServer != null) {
|
||||
runningServer.stop();
|
||||
runningServer = null;
|
||||
}
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
parseArguments(args);
|
||||
printBanner();
|
||||
|
||||
logger.info("Inicializando CyberCore Signer v0.1.0...");
|
||||
if (developmentMode) {
|
||||
logger.warn("=========================================================");
|
||||
logger.warn(" MODO DE DESENVOLVIMENTO ATIVADO (--dev)");
|
||||
logger.warn(" Logs detalhados ativos. Operação com certificados reais.");
|
||||
logger.warn("=========================================================");
|
||||
}
|
||||
|
||||
DiagnosticService diagnosticService = new DiagnosticService();
|
||||
DiagnosticReport report = diagnosticService.runDiagnostic();
|
||||
logger.info("Ambiente verificado: OS={} {} ({}), Java={} ({})",
|
||||
report.osName(), report.osVersion(), report.osArch(),
|
||||
report.javaVersion(), report.javaVendor());
|
||||
|
||||
if (!report.cryptoEngineOk()) {
|
||||
logger.error("ALERTA CRÍTICO: Engine criptográfica JCA/JCE não está totalmente operacional!");
|
||||
}
|
||||
|
||||
// Se o ambiente tiver suporte gráfico e não for headless forçado, abre a interface JavaFX
|
||||
boolean isHeadless = headlessMode || GraphicsEnvironment.isHeadless();
|
||||
|
||||
if (isHeadless) {
|
||||
logger.info("Executando em modo console / headless (sem interface gráfica).");
|
||||
startHeadlessServer();
|
||||
} else {
|
||||
try {
|
||||
logger.info("Iniciando interface gráfica desktop...");
|
||||
CyberCoreDesktopApplication.setDevelopmentMode(developmentMode);
|
||||
Application.launch(CyberCoreDesktopApplication.class, args);
|
||||
} catch (Throwable t) {
|
||||
logger.warn("Não foi possível inicializar ambiente gráfico ({}). Continuando em modo console.", t.getMessage());
|
||||
startHeadlessServer();
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("CyberCore Signer finalizado.");
|
||||
}
|
||||
|
||||
private static void startHeadlessServer() {
|
||||
try {
|
||||
CertificateManager certManager = CertificateManager.createDefault();
|
||||
if (developmentMode) {
|
||||
logger.info("Modo de desenvolvimento ativo (com certificados reais do sistema).");
|
||||
}
|
||||
SigningService signingService = new SigningService(certManager);
|
||||
runningServer = new CcspServer(certManager, signingService, null);
|
||||
runningServer.start();
|
||||
logger.info("Servidor local CCSP/1 pronto em http://127.0.0.1:{}", runningServer.getPort());
|
||||
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
|
||||
logger.info("Encerrando servidor local CCSP/1...");
|
||||
stopServer();
|
||||
}));
|
||||
|
||||
// Mantém processo vivo para atender requisições web, a menos que em modo no-wait
|
||||
if (!noWaitMode) {
|
||||
Thread.currentThread().join();
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
} catch (Exception e) {
|
||||
logger.error("Falha ao inicializar servidor local CCSP/1 no modo console: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void parseArguments(String[] args) {
|
||||
if (args != null) {
|
||||
for (String arg : args) {
|
||||
if ("--dev".equalsIgnoreCase(arg) || "-dev".equalsIgnoreCase(arg)) {
|
||||
developmentMode = true;
|
||||
} else if ("--headless".equalsIgnoreCase(arg) || "-headless".equalsIgnoreCase(arg)
|
||||
|| "--cli".equalsIgnoreCase(arg)) {
|
||||
headlessMode = true;
|
||||
} else if ("--no-wait".equalsIgnoreCase(arg) || "-no-wait".equalsIgnoreCase(arg)
|
||||
|| "--test".equalsIgnoreCase(arg)) {
|
||||
noWaitMode = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean isDevelopmentMode() {
|
||||
return developmentMode;
|
||||
}
|
||||
|
||||
public static boolean isHeadlessMode() {
|
||||
return headlessMode;
|
||||
}
|
||||
|
||||
private static void printBanner() {
|
||||
System.out.println("""
|
||||
============================================================
|
||||
____ _ ____
|
||||
/ ___| _ _ __| | ___ _ __ / ___|___ _ __ ___
|
||||
| | | | | |/ _` | / _ \\| '__| | | / _ \\ | '__|/ _ \\
|
||||
| |___ | |_| | (_| || __/| | | |__| (_) || | | __/
|
||||
\\____| \\__, |\\__,_| \\___||_| \\____\\___/ |_| \\___|
|
||||
|___/
|
||||
SIGNER AGENT v0.1.0
|
||||
============================================================
|
||||
""");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<configuration>
|
||||
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder>
|
||||
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<root level="INFO">
|
||||
<appender-ref ref="CONSOLE" />
|
||||
</root>
|
||||
|
||||
<logger name="br.com.cybercore.signer" level="DEBUG" />
|
||||
</configuration>
|
||||
@@ -0,0 +1,37 @@
|
||||
package br.com.cybercore.signer.app;
|
||||
|
||||
import com.tngtech.archunit.core.domain.JavaClasses;
|
||||
import com.tngtech.archunit.core.importer.ClassFileImporter;
|
||||
import com.tngtech.archunit.lang.ArchRule;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
|
||||
|
||||
class ArchitectureTest {
|
||||
|
||||
private final JavaClasses importedClasses = new ClassFileImporter()
|
||||
.importPackages("br.com.cybercore.signer");
|
||||
|
||||
@Test
|
||||
@DisplayName("O módulo Core não deve depender de classes dos pacotes UI ou App")
|
||||
void coreShouldNotDependOnUiOrApp() {
|
||||
ArchRule rule = noClasses()
|
||||
.that().resideInAPackage("br.com.cybercore.signer.core..")
|
||||
.should().dependOnClassesThat()
|
||||
.resideInAnyPackage("br.com.cybercore.signer.ui..", "br.com.cybercore.signer.app..");
|
||||
|
||||
rule.check(importedClasses);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("O módulo Core não deve conter dependências de bibliotecas JavaFX")
|
||||
void coreShouldNotDependOnJavaFx() {
|
||||
ArchRule rule = noClasses()
|
||||
.that().resideInAPackage("br.com.cybercore.signer.core..")
|
||||
.should().dependOnClassesThat()
|
||||
.resideInAPackage("javafx..");
|
||||
|
||||
rule.check(importedClasses);
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package br.com.cybercore.signer.app;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
|
||||
class CyberCoreSignerApplicationTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("Deve inicializar a aplicação com argumentos sem erros")
|
||||
void shouldBootstrapWithoutErrors() {
|
||||
try {
|
||||
assertThatCode(() -> CyberCoreSignerApplication.main(new String[]{"--dev", "--headless", "--no-wait"}))
|
||||
.doesNotThrowAnyException();
|
||||
} finally {
|
||||
CyberCoreSignerApplication.stopServer();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?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-ui</artifactId>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>CyberCore Signer :: UI</name>
|
||||
<description>Camada de apresentação JavaFX, System Tray nativo, diálogos e gerenciamento de temas.</description>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>br.com.cybercore.signer</groupId>
|
||||
<artifactId>cybercore-core</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.openjfx</groupId>
|
||||
<artifactId>javafx-controls</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.openjfx</groupId>
|
||||
<artifactId>javafx-graphics</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.openjfx</groupId>
|
||||
<artifactId>javafx-base</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-api</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>
|
||||
+496
@@ -0,0 +1,496 @@
|
||||
package br.com.cybercore.signer.ui;
|
||||
|
||||
import br.com.cybercore.signer.core.crypto.SigningService;
|
||||
import br.com.cybercore.signer.core.diagnostics.DiagnosticService;
|
||||
import br.com.cybercore.signer.core.domain.ManagedCertificate;
|
||||
import br.com.cybercore.signer.core.domain.SigningContext;
|
||||
import br.com.cybercore.signer.core.domain.SigningResult;
|
||||
import br.com.cybercore.signer.core.protocol.CcspErrorCode;
|
||||
import br.com.cybercore.signer.core.provider.CertificateManager;
|
||||
import br.com.cybercore.signer.core.provider.CertificateProviderException;
|
||||
import br.com.cybercore.signer.core.provider.Pkcs12CertificateProvider;
|
||||
import br.com.cybercore.signer.ui.view.A1PasswordDialog;
|
||||
import br.com.cybercore.signer.ui.view.CertificateDetailsDialog;
|
||||
import br.com.cybercore.signer.ui.view.MainAppView;
|
||||
import br.com.cybercore.signer.ui.theme.ThemeManager;
|
||||
import br.com.cybercore.signer.core.protocol.CcspRequest;
|
||||
import br.com.cybercore.signer.core.protocol.CcspServer;
|
||||
import br.com.cybercore.signer.core.protocol.CcspUserPromptListener;
|
||||
import br.com.cybercore.signer.core.security.ApplicationRegistry;
|
||||
import br.com.cybercore.signer.core.security.AuthorizedApplication;
|
||||
import br.com.cybercore.signer.core.security.SmartCardWatcher;
|
||||
import br.com.cybercore.signer.ui.components.SvgIcons;
|
||||
import br.com.cybercore.signer.ui.view.PdfSignDialog;
|
||||
import br.com.cybercore.signer.ui.view.XmlSignDialog;
|
||||
import br.com.cybercore.signer.ui.tray.SystemTrayManager;
|
||||
import br.com.cybercore.signer.ui.view.AboutView;
|
||||
import br.com.cybercore.signer.ui.view.ApplicationApprovalDialog;
|
||||
import br.com.cybercore.signer.ui.view.BatchSigningDialog;
|
||||
import br.com.cybercore.signer.ui.view.CertificatesManagerDialog;
|
||||
import br.com.cybercore.signer.ui.view.FileSignDialog;
|
||||
import br.com.cybercore.signer.ui.view.LogsViewerDialog;
|
||||
import br.com.cybercore.signer.ui.view.SealDesignerDialog;
|
||||
import br.com.cybercore.signer.ui.view.SealManagementDialog;
|
||||
import br.com.cybercore.signer.ui.view.SettingsView;
|
||||
import br.com.cybercore.signer.ui.view.SignaturePromptDialog;
|
||||
import br.com.cybercore.signer.ui.view.SignatureValidationDialog;
|
||||
import br.com.cybercore.signer.ui.view.TutorialDialog;
|
||||
import javafx.application.Application;
|
||||
import javafx.application.Platform;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.ScrollPane;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.stage.Modality;
|
||||
import javafx.scene.control.Alert;
|
||||
import javafx.scene.control.ButtonType;
|
||||
import javafx.stage.FileChooser;
|
||||
import javafx.stage.Stage;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
/**
|
||||
* Ponto de entrada JavaFX da interface desktop do CyberCore Signer.
|
||||
* Conectado diretamente ao CertificateManager e SigningService reais do sistema operacional.
|
||||
*/
|
||||
public class CyberCoreDesktopApplication extends Application {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(CyberCoreDesktopApplication.class);
|
||||
|
||||
private static boolean developmentMode = false;
|
||||
private static MainAppView mainAppView;
|
||||
|
||||
private CertificateManager certificateManager;
|
||||
private SigningService signingService;
|
||||
private CcspServer ccspServer;
|
||||
private SystemTrayManager systemTrayManager;
|
||||
private SmartCardWatcher smartCardWatcher;
|
||||
|
||||
public static void setDevelopmentMode(boolean devMode) {
|
||||
developmentMode = devMode;
|
||||
}
|
||||
|
||||
public static MainAppView getMainAppView() {
|
||||
return mainAppView;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start(Stage primaryStage) {
|
||||
logger.info("Iniciando interface gráfica JavaFX...");
|
||||
Platform.setImplicitExit(false);
|
||||
|
||||
// Inicializa serviços centrais reais da plataforma
|
||||
this.certificateManager = CertificateManager.createDefault();
|
||||
|
||||
if (developmentMode) {
|
||||
logger.info("Modo de desenvolvimento ativo (utilizando certificados reais da máquina).");
|
||||
}
|
||||
|
||||
mainAppView = new MainAppView(primaryStage);
|
||||
|
||||
// Ações da Grade de Assinatura e Validação
|
||||
mainAppView.setOnSignPdf(() -> new PdfSignDialog(primaryStage, certificateManager).show());
|
||||
mainAppView.setOnSignXml(() -> new XmlSignDialog(primaryStage, certificateManager).show());
|
||||
mainAppView.setOnSignFile(() -> new FileSignDialog(primaryStage, "Assinar Arquivo", "*.*", SvgIcons.fileIcon(), certificateManager, signingService).show());
|
||||
mainAppView.setOnBatchSign(() -> new BatchSigningDialog(primaryStage, certificateManager, signingService).show());
|
||||
mainAppView.setOnAttachedSign(() -> new FileSignDialog(primaryStage, "Assinatura Atachada", "*.*", SvgIcons.attachedIcon(), certificateManager, signingService).show());
|
||||
mainAppView.setOnCoSign(() -> new FileSignDialog(primaryStage, "Co-Assinar Documento", "*.*", SvgIcons.coSignIcon(), certificateManager, signingService).show());
|
||||
mainAppView.setOnTimestamp(() -> new SignatureValidationDialog(primaryStage, true).show());
|
||||
mainAppView.setOnLtvSign(() -> new FileSignDialog(primaryStage, "Assinatura de Longa Duração (LTV)", "*.*", SvgIcons.ltvIcon(), certificateManager, signingService).show());
|
||||
mainAppView.setOnValidateSignature(() -> new SignatureValidationDialog(primaryStage, false).show());
|
||||
mainAppView.setOnValidateTimestamp(() -> new SignatureValidationDialog(primaryStage, true).show());
|
||||
mainAppView.setOnManageSeals(() -> new SealManagementDialog(primaryStage).show());
|
||||
mainAppView.setOnExit(() -> {
|
||||
logger.info("Encerrando CyberCore Signer pela interface gráfica.");
|
||||
primaryStage.close();
|
||||
Platform.exit();
|
||||
System.exit(0);
|
||||
});
|
||||
|
||||
// Ações do Painel Lateral
|
||||
mainAppView.setOnLogs(() -> new LogsViewerDialog(primaryStage).show());
|
||||
mainAppView.setOnSettings(() -> openSettingsDialog(primaryStage));
|
||||
mainAppView.setOnTutorial(() -> new TutorialDialog(primaryStage).show());
|
||||
mainAppView.setOnCertificates(() -> new CertificatesManagerDialog(primaryStage, certificateManager).show());
|
||||
mainAppView.setOnAbout(() -> openAboutDialog(primaryStage));
|
||||
|
||||
// Inicializa o servidor local do protocolo CCSP/1 para atender requisições do navegador
|
||||
initCcspServer(primaryStage);
|
||||
|
||||
// Monitoramento em tempo real de conexão/remoção de Smart Cards e Tokens USB
|
||||
initSmartCardWatcher();
|
||||
|
||||
// Configura bandeja do sistema (System Tray) para operação contínua em segundo plano
|
||||
this.systemTrayManager = new SystemTrayManager(primaryStage, this::refreshCertificates);
|
||||
this.systemTrayManager.setup();
|
||||
|
||||
// Carrega os certificados instalados na máquina do usuário
|
||||
refreshCertificates();
|
||||
|
||||
mainAppView.show();
|
||||
logger.info("Janela principal JavaFX exibida.");
|
||||
}
|
||||
|
||||
private void refreshCertificates() {
|
||||
try {
|
||||
List<ManagedCertificate> certs = certificateManager.discoverCertificates();
|
||||
logger.info("Descoberta concluída: {} certificado(s) real(is) identificado(s).", certs.size());
|
||||
} catch (Exception e) {
|
||||
logger.error("Erro ao atualizar lista de certificados: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private void openSettingsDialog(Stage owner) {
|
||||
Stage dlg = new Stage();
|
||||
dlg.initModality(Modality.APPLICATION_MODAL);
|
||||
dlg.initOwner(owner);
|
||||
dlg.setTitle("CyberCore Signer — Configurações");
|
||||
dlg.setResizable(true);
|
||||
dlg.setMinWidth(660);
|
||||
dlg.setMinHeight(520);
|
||||
|
||||
SettingsView settingsView = new SettingsView();
|
||||
settingsView.updateApplications(ApplicationRegistry.getInstance().listAll());
|
||||
settingsView.setOnRevokeApplication(appId -> {
|
||||
logger.info("Revogando autorização da aplicação web [id={}]", appId);
|
||||
ApplicationRegistry.getInstance().revoke(appId);
|
||||
settingsView.updateApplications(ApplicationRegistry.getInstance().listAll());
|
||||
});
|
||||
|
||||
ScrollPane scrollPane = new ScrollPane(settingsView);
|
||||
scrollPane.setFitToWidth(true);
|
||||
scrollPane.setStyle("-fx-background: transparent; -fx-background-color: transparent;");
|
||||
VBox.setVgrow(scrollPane, Priority.ALWAYS);
|
||||
|
||||
Button btnClose = new Button(" Fechar", SvgIcons.closeIcon(13));
|
||||
btnClose.getStyleClass().add("btn-secondary");
|
||||
btnClose.setPrefWidth(95);
|
||||
btnClose.setOnAction(e -> dlg.close());
|
||||
|
||||
HBox footer = new HBox(btnClose);
|
||||
footer.setAlignment(Pos.CENTER_RIGHT);
|
||||
footer.setPadding(new Insets(10, 24, 16, 24));
|
||||
|
||||
VBox layout = new VBox(scrollPane, footer);
|
||||
layout.getStyleClass().add("dialog-container");
|
||||
|
||||
Scene sc = new Scene(layout, 720, 580);
|
||||
ThemeManager.applyTheme(sc);
|
||||
dlg.setScene(sc);
|
||||
dlg.show();
|
||||
}
|
||||
|
||||
private void openAboutDialog(Stage owner) {
|
||||
Stage dlg = new Stage();
|
||||
dlg.initModality(Modality.APPLICATION_MODAL);
|
||||
dlg.initOwner(owner);
|
||||
dlg.setTitle("CyberCore Signer — Sobre");
|
||||
dlg.setResizable(true);
|
||||
dlg.setMinWidth(540);
|
||||
dlg.setMinHeight(420);
|
||||
|
||||
AboutView aboutView = new AboutView();
|
||||
Button btnClose = new Button(" Fechar", SvgIcons.closeIcon(13));
|
||||
btnClose.getStyleClass().add("btn-primary");
|
||||
btnClose.setPrefWidth(95);
|
||||
btnClose.setOnAction(e -> dlg.close());
|
||||
|
||||
HBox footer = new HBox(btnClose);
|
||||
footer.setAlignment(Pos.CENTER);
|
||||
footer.setPadding(new Insets(10, 24, 20, 24));
|
||||
|
||||
VBox layout = new VBox(aboutView, footer);
|
||||
layout.getStyleClass().add("dialog-container");
|
||||
|
||||
Scene sc = new Scene(layout, 560, 440);
|
||||
ThemeManager.applyTheme(sc);
|
||||
dlg.setScene(sc);
|
||||
dlg.show();
|
||||
}
|
||||
|
||||
private void showCertificateDetails(Stage owner, ManagedCertificate cert) {
|
||||
CertificateDetailsDialog dialog = new CertificateDetailsDialog(owner, cert);
|
||||
dialog.show();
|
||||
}
|
||||
|
||||
private void handleAddA1Certificate(Stage owner) {
|
||||
FileChooser fileChooser = new FileChooser();
|
||||
fileChooser.setTitle("Selecionar Certificado Digital A1 (.pfx, .p12)");
|
||||
fileChooser.getExtensionFilters().addAll(
|
||||
new FileChooser.ExtensionFilter("Certificados Digitais PKCS#12 (*.pfx, *.p12)", "*.pfx", "*.p12"),
|
||||
new FileChooser.ExtensionFilter("Todos os Arquivos (*.*)", "*.*")
|
||||
);
|
||||
|
||||
File selectedFile = fileChooser.showOpenDialog(owner);
|
||||
if (selectedFile == null) {
|
||||
logger.info("Seleção de certificado A1 cancelada pelo usuário.");
|
||||
return;
|
||||
}
|
||||
|
||||
A1PasswordDialog pwdDialog = new A1PasswordDialog(owner, selectedFile.getName());
|
||||
pwdDialog.showAndAwait().thenAccept(password -> {
|
||||
if (password == null) {
|
||||
logger.info("Importação de certificado A1 cancelada na etapa de senha.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
byte[] pfxData = Files.readAllBytes(selectedFile.toPath());
|
||||
String providerId = "provider-a1-" + Integer.toHexString(selectedFile.getAbsolutePath().hashCode());
|
||||
Pkcs12CertificateProvider a1Provider = new Pkcs12CertificateProvider(providerId, selectedFile.getName());
|
||||
|
||||
a1Provider.loadFromBytes(pfxData, password);
|
||||
|
||||
// Limpa senha da memória
|
||||
Arrays.fill(password, ' ');
|
||||
|
||||
certificateManager.registerProvider(a1Provider);
|
||||
refreshCertificates();
|
||||
|
||||
Platform.runLater(() -> {
|
||||
Alert alert = new Alert(Alert.AlertType.INFORMATION, "Certificado A1 importado com sucesso!", ButtonType.OK);
|
||||
alert.initOwner(owner);
|
||||
alert.setTitle("CyberCore Signer — Certificado A1");
|
||||
alert.setHeaderText("Importação Concluída");
|
||||
alert.show();
|
||||
});
|
||||
|
||||
logger.info("Certificado A1 '{}' importado e registrado com sucesso.", selectedFile.getName());
|
||||
|
||||
} catch (CertificateProviderException cpe) {
|
||||
Arrays.fill(password, ' ');
|
||||
Platform.runLater(() -> {
|
||||
String msg = cpe.getErrorCode() == CcspErrorCode.PIN_INCORRECT
|
||||
? "A senha informada para o certificado A1 está incorreta."
|
||||
: "Erro ao processar certificado: " + cpe.getMessage();
|
||||
|
||||
Alert alert = new Alert(Alert.AlertType.ERROR, msg, ButtonType.OK);
|
||||
alert.initOwner(owner);
|
||||
alert.setTitle("CyberCore Signer — Erro");
|
||||
alert.setHeaderText("Falha na Importação");
|
||||
alert.show();
|
||||
});
|
||||
} catch (Exception e) {
|
||||
Arrays.fill(password, ' ');
|
||||
Platform.runLater(() -> {
|
||||
Alert alert = new Alert(Alert.AlertType.ERROR, "Não foi possível ler o arquivo: " + e.getMessage(), ButtonType.OK);
|
||||
alert.initOwner(owner);
|
||||
alert.setTitle("CyberCore Signer — Erro");
|
||||
alert.setHeaderText("Falha na Leitura");
|
||||
alert.show();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void runSelfTestSignature(Stage stage) {
|
||||
List<ManagedCertificate> available = certificateManager.getAvailableCertificates();
|
||||
if (available.isEmpty()) {
|
||||
logger.warn("Nenhum certificado digital disponível com chave privada para teste de assinatura.");
|
||||
Platform.runLater(() -> {
|
||||
Alert alert = new Alert(Alert.AlertType.WARNING,
|
||||
"Nenhum certificado digital disponível com chave privada para teste de assinatura.\n\n" +
|
||||
"Conecte um token USB/Smart Card ou importe um certificado A1.",
|
||||
ButtonType.OK);
|
||||
alert.initOwner(stage);
|
||||
alert.setTitle("CyberCore Signer — Auto-teste");
|
||||
alert.setHeaderText("Certificado Não Encontrado");
|
||||
alert.show();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
ManagedCertificate target = available.get(0);
|
||||
openSignaturePrompt(stage, target);
|
||||
}
|
||||
|
||||
private void openSignaturePrompt(Stage owner, ManagedCertificate cert) {
|
||||
try {
|
||||
byte[] docDigest = MessageDigest.getInstance("SHA-256")
|
||||
.digest(("Documento_Teste_CyberCore_" + Instant.now()).getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
SigningContext context = new SigningContext(
|
||||
"req_test_" + System.currentTimeMillis(),
|
||||
"app_teste_local",
|
||||
docDigest,
|
||||
"SHA256withRSA",
|
||||
cert.id(),
|
||||
Instant.now()
|
||||
);
|
||||
|
||||
SignaturePromptDialog dialog = new SignaturePromptDialog(
|
||||
owner, "CyberCore Agente Local", "http://localhost", cert, context
|
||||
);
|
||||
|
||||
dialog.showAndAwait().thenAccept(approved -> {
|
||||
if (Boolean.TRUE.equals(approved)) {
|
||||
logger.info("Usuário aprovou a assinatura para o certificado: {}", cert.alias());
|
||||
try {
|
||||
SigningResult result = signingService.sign(context);
|
||||
logger.info("Assinatura real gerada com sucesso! Algoritmo={}, Tamanho={}",
|
||||
result.algorithm(), result.signatureBytes().length);
|
||||
|
||||
Platform.runLater(() -> {
|
||||
Alert alert = new Alert(Alert.AlertType.INFORMATION,
|
||||
"Documento assinado com sucesso via " + result.algorithm() + "!\n\n" +
|
||||
"Tamanho da assinatura: " + result.signatureBytes().length + " bytes\n" +
|
||||
"Certificado: " + cert.commonName(),
|
||||
ButtonType.OK);
|
||||
alert.initOwner(owner);
|
||||
alert.setTitle("CyberCore Signer — Sucesso");
|
||||
alert.setHeaderText("Assinatura Realizada");
|
||||
alert.show();
|
||||
});
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("Falha ao executar assinatura: {}", e.getMessage(), e);
|
||||
Platform.runLater(() -> {
|
||||
Alert alert = new Alert(Alert.AlertType.ERROR,
|
||||
"Falha ao realizar assinatura digital:\n\n" + e.getMessage(),
|
||||
ButtonType.OK);
|
||||
alert.initOwner(owner);
|
||||
alert.setTitle("CyberCore Signer — Erro");
|
||||
alert.setHeaderText("Falha na Assinatura");
|
||||
alert.show();
|
||||
});
|
||||
}
|
||||
} else {
|
||||
logger.info("Usuário cancelou a assinatura para o certificado: {}", cert.alias());
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
logger.error("Erro ao abrir diálogo de solicitação de assinatura: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private void initCcspServer(Stage stage) {
|
||||
CcspUserPromptListener promptListener = new CcspUserPromptListener() {
|
||||
@Override
|
||||
public CompletableFuture<Boolean> requestApplicationAuthorization(String origin, String appName, String applicationId) {
|
||||
CompletableFuture<Boolean> future = new CompletableFuture<>();
|
||||
Platform.runLater(() -> {
|
||||
try {
|
||||
ApplicationApprovalDialog dialog = new ApplicationApprovalDialog(stage, origin, appName, applicationId);
|
||||
dialog.showAndAwait().whenComplete((approved, err) -> {
|
||||
if (err != null) {
|
||||
future.complete(false);
|
||||
} else {
|
||||
future.complete(Boolean.TRUE.equals(approved));
|
||||
}
|
||||
});
|
||||
} catch (Exception ex) {
|
||||
logger.error("Erro ao abrir diálogo de autorização de aplicação: {}", ex.getMessage());
|
||||
future.complete(false);
|
||||
}
|
||||
});
|
||||
return future;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<Boolean> requestSignatureConfirmation(CcspRequest request, ManagedCertificate certificate) {
|
||||
CompletableFuture<Boolean> future = new CompletableFuture<>();
|
||||
Platform.runLater(() -> {
|
||||
try {
|
||||
byte[] digestBytes;
|
||||
if (request.digest() != null && !request.digest().isBlank()) {
|
||||
digestBytes = Base64.getDecoder().decode(request.digest());
|
||||
} else if (request.challenge() != null && !request.challenge().isBlank()) {
|
||||
digestBytes = MessageDigest.getInstance("SHA-256")
|
||||
.digest(request.challenge().getBytes(StandardCharsets.UTF_8));
|
||||
} else {
|
||||
digestBytes = new byte[32];
|
||||
}
|
||||
|
||||
SigningContext context = new SigningContext(
|
||||
request.requestId(),
|
||||
request.applicationId(),
|
||||
digestBytes,
|
||||
request.algorithm() != null ? request.algorithm() : "SHA256withRSA",
|
||||
certificate.id(),
|
||||
Instant.now()
|
||||
);
|
||||
|
||||
String appName = ApplicationRegistry.getInstance().findById(request.applicationId())
|
||||
.map(AuthorizedApplication::name)
|
||||
.orElse(request.applicationId());
|
||||
|
||||
SignaturePromptDialog dialog = new SignaturePromptDialog(
|
||||
stage, appName, "Requisição Web CCSP", certificate, context,
|
||||
request.documentName(), request.documentType(), request.isAuthentication()
|
||||
);
|
||||
dialog.showAndAwait().whenComplete((approved, err) -> {
|
||||
if (err != null) {
|
||||
future.complete(false);
|
||||
} else {
|
||||
future.complete(Boolean.TRUE.equals(approved));
|
||||
}
|
||||
});
|
||||
} catch (Exception ex) {
|
||||
logger.error("Erro ao abrir diálogo de confirmação de assinatura: {}", ex.getMessage());
|
||||
future.complete(false);
|
||||
}
|
||||
});
|
||||
return future;
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
this.ccspServer = new CcspServer(this.certificateManager, this.signingService, promptListener);
|
||||
this.ccspServer.start();
|
||||
logger.info("Servidor local CCSP/1 inicializado na porta {}", this.ccspServer.getPort());
|
||||
} catch (Exception e) {
|
||||
logger.error("Falha ao inicializar servidor local CCSP/1: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private void initSmartCardWatcher() {
|
||||
try {
|
||||
this.smartCardWatcher = new SmartCardWatcher();
|
||||
this.smartCardWatcher.addListener(new SmartCardWatcher.CardEventListener() {
|
||||
@Override
|
||||
public void onCardInserted(String terminalName) {
|
||||
logger.info("Novo dispositivo detectado no terminal '{}'. Atualizando certificados...", terminalName);
|
||||
refreshCertificates();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCardRemoved(String terminalName) {
|
||||
logger.warn("Dispositivo desconectado do terminal '{}'. Atualizando certificados...", terminalName);
|
||||
refreshCertificates();
|
||||
}
|
||||
});
|
||||
this.smartCardWatcher.start();
|
||||
} catch (Throwable t) {
|
||||
logger.warn("Não foi possível iniciar monitor dinâmico de smart cards: {}", t.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
logger.info("Encerrando componentes da interface JavaFX...");
|
||||
if (smartCardWatcher != null) {
|
||||
smartCardWatcher.stop();
|
||||
}
|
||||
if (systemTrayManager != null) {
|
||||
systemTrayManager.remove();
|
||||
}
|
||||
if (ccspServer != null) {
|
||||
ccspServer.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package br.com.cybercore.signer.ui;
|
||||
|
||||
/**
|
||||
* Constantes visuais e de apresentação da interface do CyberCore Signer.
|
||||
*/
|
||||
public final class CyberCoreUiConstants {
|
||||
|
||||
public static final String APP_NAME = "CyberCore Signer";
|
||||
public static final String APP_VERSION = "0.1.0";
|
||||
public static final String APP_STATUS_READY = "Serviço ativo e pronto para assinar";
|
||||
|
||||
// Temas e estilos
|
||||
public static final String MAIN_CSS_PATH = "/styles/theme.css";
|
||||
|
||||
private CyberCoreUiConstants() {
|
||||
}
|
||||
}
|
||||
+449
@@ -0,0 +1,449 @@
|
||||
package br.com.cybercore.signer.ui.assets;
|
||||
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Group;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.image.Image;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.scene.paint.Color;
|
||||
import javafx.scene.shape.Circle;
|
||||
import javafx.scene.shape.Line;
|
||||
import javafx.scene.shape.Polygon;
|
||||
import javafx.scene.shape.Polyline;
|
||||
import javafx.scene.shape.Rectangle;
|
||||
import javafx.scene.shape.SVGPath;
|
||||
import javafx.scene.shape.Shape;
|
||||
import javafx.scene.text.Font;
|
||||
import javafx.scene.text.FontWeight;
|
||||
import javafx.scene.text.Text;
|
||||
import javafx.scene.transform.Scale;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.w3c.dom.Document;
|
||||
import org.w3c.dom.Element;
|
||||
import org.w3c.dom.NodeList;
|
||||
|
||||
import javax.xml.parsers.DocumentBuilder;
|
||||
import javax.xml.parsers.DocumentBuilderFactory;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* Gerenciador centralizado de recursos visuais (ícones e imagens).
|
||||
* Permite que o usuário customize ou substitua facilmente qualquer ícone (SVG/PNG) ou imagem (PNG/JPG)
|
||||
* do CyberCore Signer simplesmente colocando os arquivos na pasta 'assets/'
|
||||
* na raiz do projeto ou no diretório ~/.cybercore/assets/.
|
||||
*/
|
||||
public final class AssetManager {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(AssetManager.class);
|
||||
|
||||
private static final String[] RASTER_EXTENSIONS = {".png", ".jpg", ".jpeg"};
|
||||
|
||||
private AssetManager() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Retorna o caminho do diretório externo prioritário de assets.
|
||||
*/
|
||||
public static Path getExternalAssetsDir() {
|
||||
Path localAssets = Path.of("assets");
|
||||
if (Files.isDirectory(localAssets)) {
|
||||
return localAssets;
|
||||
}
|
||||
|
||||
String userHome = System.getProperty("user.home", ".");
|
||||
return Path.of(userHome, ".cybercore", "assets");
|
||||
}
|
||||
|
||||
/**
|
||||
* Tenta carregar uma imagem bitmap personalizada (.png, .jpg) pelo nome nas pastas de assets
|
||||
* ou nos recursos empacotados.
|
||||
*/
|
||||
public static Optional<Image> loadImage(String category, String baseName, double width, double height) {
|
||||
if (baseName == null || baseName.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
String cleanName = cleanBaseName(baseName);
|
||||
|
||||
// 1. Procura no diretório local ./assets/{category}/
|
||||
Path localDir = Path.of("assets", category);
|
||||
for (String ext : RASTER_EXTENSIONS) {
|
||||
Path file = localDir.resolve(cleanName + ext);
|
||||
if (Files.isRegularFile(file)) {
|
||||
try (InputStream in = new FileInputStream(file.toFile())) {
|
||||
logger.debug("Carregando imagem bitmap do diretório local: {}", file);
|
||||
return Optional.of(new Image(in, width, height, true, true));
|
||||
} catch (Exception e) {
|
||||
logger.warn("Falha ao ler imagem customizada {}: {}", file, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Procura em ~/.cybercore/assets/{category}/
|
||||
String userHome = System.getProperty("user.home", ".");
|
||||
Path userDir = Path.of(userHome, ".cybercore", "assets", category);
|
||||
for (String ext : RASTER_EXTENSIONS) {
|
||||
Path file = userDir.resolve(cleanName + ext);
|
||||
if (Files.isRegularFile(file)) {
|
||||
try (InputStream in = new FileInputStream(file.toFile())) {
|
||||
logger.debug("Carregando imagem bitmap de ~/.cybercore: {}", file);
|
||||
return Optional.of(new Image(in, width, height, true, true));
|
||||
} catch (Exception e) {
|
||||
logger.warn("Falha ao ler imagem do usuário {}: {}", file, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Procura no Classpath interno (/assets/{category}/{name})
|
||||
for (String ext : RASTER_EXTENSIONS) {
|
||||
String resPath = "/assets/" + category + "/" + cleanName + ext;
|
||||
InputStream resStream = AssetManager.class.getResourceAsStream(resPath);
|
||||
if (resStream != null) {
|
||||
try (resStream) {
|
||||
return Optional.of(new Image(resStream, width, height, true, true));
|
||||
} catch (Exception e) {
|
||||
logger.warn("Falha ao ler recurso interno {}: {}", resPath, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retorna um Node de ícone customizado (SVG ou PNG) ou invoca o fallback caso não exista imagem customizada.
|
||||
*/
|
||||
public static Node getIconOrDefault(String iconName, double size, Supplier<Node> fallbackSupplier) {
|
||||
if (iconName != null && !iconName.isBlank()) {
|
||||
String cleanName = cleanBaseName(iconName);
|
||||
|
||||
// 1. Prioridade para SVG em ./assets/icons/
|
||||
Optional<Node> localSvg = loadSvgIconFromPath(Path.of("assets", "icons", cleanName + ".svg"), size);
|
||||
if (localSvg.isPresent()) {
|
||||
return localSvg.get();
|
||||
}
|
||||
|
||||
// 2. Procura SVG em ~/.cybercore/assets/icons/
|
||||
String userHome = System.getProperty("user.home", ".");
|
||||
Optional<Node> userSvg = loadSvgIconFromPath(Path.of(userHome, ".cybercore", "assets", "icons", cleanName + ".svg"), size);
|
||||
if (userSvg.isPresent()) {
|
||||
return userSvg.get();
|
||||
}
|
||||
|
||||
// 3. Se não houver SVG, tenta imagem bitmap (.png, .jpg) nas pastas externas
|
||||
Optional<Image> customImg = loadImage("icons", cleanName, size, size);
|
||||
if (customImg.isPresent() && !customImg.get().isError()) {
|
||||
ImageView iv = new ImageView(customImg.get());
|
||||
iv.setFitWidth(size);
|
||||
iv.setFitHeight(size);
|
||||
iv.setPreserveRatio(true);
|
||||
return iv;
|
||||
}
|
||||
}
|
||||
|
||||
return fallbackSupplier != null ? fallbackSupplier.get() : new ImageView();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retorna a imagem do logotipo do sistema ou o fallback vetorial.
|
||||
*/
|
||||
public static Node getLogoOrDefault(double width, double height, Supplier<Node> fallbackSupplier) {
|
||||
// Tenta carregar app-logo bitmap (.png) primeiro
|
||||
Optional<Image> customLogo = loadImage("images", "app-logo", width, height);
|
||||
if (customLogo.isPresent() && !customLogo.get().isError()) {
|
||||
ImageView iv = new ImageView(customLogo.get());
|
||||
iv.setFitWidth(width);
|
||||
iv.setFitHeight(height);
|
||||
iv.setPreserveRatio(true);
|
||||
return iv;
|
||||
}
|
||||
|
||||
// Tenta carregar SVG de app-logo se existir
|
||||
Optional<Node> svgLogo = loadSvgIconFromPath(Path.of("assets", "images", "app-logo.svg"), height);
|
||||
if (svgLogo.isPresent()) {
|
||||
return svgLogo.get();
|
||||
}
|
||||
|
||||
return fallbackSupplier != null ? fallbackSupplier.get() : new ImageView();
|
||||
}
|
||||
|
||||
private static String cleanBaseName(String name) {
|
||||
String clean = name;
|
||||
int dotIdx = clean.lastIndexOf('.');
|
||||
if (dotIdx > 0) {
|
||||
clean = clean.substring(0, dotIdx);
|
||||
}
|
||||
return clean;
|
||||
}
|
||||
|
||||
private static Optional<Node> loadSvgIconFromPath(Path path, double size) {
|
||||
if (Files.isRegularFile(path)) {
|
||||
try (InputStream in = new FileInputStream(path.toFile())) {
|
||||
Node node = parseSvgToNode(in, size);
|
||||
if (node != null) {
|
||||
logger.debug("Carregado ícone SVG customizado de: {}", path);
|
||||
return Optional.of(node);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.warn("Falha ao carregar ícone SVG {}: {}", path, e.getMessage());
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parser analítico leve de SVG padrão para nós gráficos JavaFX com preservação geométrica exata.
|
||||
*/
|
||||
public static Node parseSvgToNode(InputStream in, double targetSize) {
|
||||
try {
|
||||
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
|
||||
dbf.setNamespaceAware(false);
|
||||
dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
|
||||
DocumentBuilder db = dbf.newDocumentBuilder();
|
||||
Document doc = db.parse(in);
|
||||
|
||||
Element svg = doc.getDocumentElement();
|
||||
if (!"svg".equalsIgnoreCase(svg.getNodeName())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
double vbMinX = 0;
|
||||
double vbMinY = 0;
|
||||
double vbWidth = 48;
|
||||
double vbHeight = 48;
|
||||
|
||||
String viewBox = svg.getAttribute("viewBox");
|
||||
if (viewBox != null && !viewBox.isBlank()) {
|
||||
String[] parts = viewBox.trim().split("[,\\s]+");
|
||||
if (parts.length >= 4) {
|
||||
try {
|
||||
vbMinX = Double.parseDouble(parts[0]);
|
||||
vbMinY = Double.parseDouble(parts[1]);
|
||||
vbWidth = Double.parseDouble(parts[2]);
|
||||
vbHeight = Double.parseDouble(parts[3]);
|
||||
} catch (NumberFormatException ignored) {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
String wAttr = svg.getAttribute("width");
|
||||
String hAttr = svg.getAttribute("height");
|
||||
if (!wAttr.isBlank() && !hAttr.isBlank()) {
|
||||
try {
|
||||
vbWidth = Double.parseDouble(wAttr.replaceAll("[^0-9.]", ""));
|
||||
vbHeight = Double.parseDouble(hAttr.replaceAll("[^0-9.]", ""));
|
||||
} catch (NumberFormatException ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (vbWidth <= 0) vbWidth = 48;
|
||||
if (vbHeight <= 0) vbHeight = 48;
|
||||
|
||||
Group contentGroup = new Group();
|
||||
renderChildren(svg, contentGroup);
|
||||
|
||||
double maxDim = Math.max(vbWidth, vbHeight);
|
||||
double scale = (targetSize * 0.90) / maxDim;
|
||||
|
||||
double drawnW = vbWidth * scale;
|
||||
double drawnH = vbHeight * scale;
|
||||
double tx = (targetSize - drawnW) / 2.0 - (vbMinX * scale);
|
||||
double ty = (targetSize - drawnH) / 2.0 - (vbMinY * scale);
|
||||
|
||||
contentGroup.getTransforms().setAll(
|
||||
new javafx.scene.transform.Translate(tx, ty),
|
||||
new javafx.scene.transform.Scale(scale, scale, 0, 0)
|
||||
);
|
||||
|
||||
StackPane container = new StackPane(contentGroup);
|
||||
container.setPrefSize(targetSize, targetSize);
|
||||
container.setMaxSize(targetSize, targetSize);
|
||||
container.setMinSize(targetSize, targetSize);
|
||||
container.setAlignment(Pos.CENTER);
|
||||
return container;
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.warn("Erro ao parsear arquivo SVG: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void renderChildren(Element parent, Group targetGroup) {
|
||||
NodeList children = parent.getChildNodes();
|
||||
for (int i = 0; i < children.getLength(); i++) {
|
||||
org.w3c.dom.Node n = children.item(i);
|
||||
if (n instanceof Element el) {
|
||||
Node jfxNode = createElementNode(el);
|
||||
if (jfxNode != null) {
|
||||
targetGroup.getChildren().add(jfxNode);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Node createElementNode(Element el) {
|
||||
String tagName = el.getTagName().toLowerCase();
|
||||
Shape shape = null;
|
||||
|
||||
switch (tagName) {
|
||||
case "path" -> {
|
||||
String d = el.getAttribute("d");
|
||||
if (!d.isBlank()) {
|
||||
SVGPath p = new SVGPath();
|
||||
p.setContent(d);
|
||||
shape = p;
|
||||
}
|
||||
}
|
||||
case "rect" -> {
|
||||
double x = parseDouble(el.getAttribute("x"), 0);
|
||||
double y = parseDouble(el.getAttribute("y"), 0);
|
||||
double w = parseDouble(el.getAttribute("width"), 0);
|
||||
double h = parseDouble(el.getAttribute("height"), 0);
|
||||
double rx = parseDouble(el.getAttribute("rx"), 0);
|
||||
Rectangle r = new Rectangle(x, y, w, h);
|
||||
if (rx > 0) {
|
||||
r.setArcWidth(rx * 2);
|
||||
r.setArcHeight(rx * 2);
|
||||
}
|
||||
shape = r;
|
||||
}
|
||||
case "circle" -> {
|
||||
double cx = parseDouble(el.getAttribute("cx"), 0);
|
||||
double cy = parseDouble(el.getAttribute("cy"), 0);
|
||||
double rad = parseDouble(el.getAttribute("r"), 0);
|
||||
shape = new Circle(cx, cy, rad);
|
||||
}
|
||||
case "line" -> {
|
||||
double x1 = parseDouble(el.getAttribute("x1"), 0);
|
||||
double y1 = parseDouble(el.getAttribute("y1"), 0);
|
||||
double x2 = parseDouble(el.getAttribute("x2"), 0);
|
||||
double y2 = parseDouble(el.getAttribute("y2"), 0);
|
||||
shape = new Line(x1, y1, x2, y2);
|
||||
}
|
||||
case "polygon" -> {
|
||||
String pts = el.getAttribute("points");
|
||||
if (!pts.isBlank()) {
|
||||
Polygon poly = new Polygon();
|
||||
parsePoints(pts, poly.getPoints());
|
||||
shape = poly;
|
||||
}
|
||||
}
|
||||
case "polyline" -> {
|
||||
String pts = el.getAttribute("points");
|
||||
if (!pts.isBlank()) {
|
||||
Polyline poly = new Polyline();
|
||||
parsePoints(pts, poly.getPoints());
|
||||
shape = poly;
|
||||
}
|
||||
}
|
||||
case "text" -> {
|
||||
double x = parseDouble(el.getAttribute("x"), 0);
|
||||
double y = parseDouble(el.getAttribute("y"), 0);
|
||||
String content = el.getTextContent();
|
||||
Text t = new Text(x, y, content);
|
||||
double fs = parseDouble(el.getAttribute("font-size"), 10);
|
||||
String fw = el.getAttribute("font-weight");
|
||||
t.setFont(Font.font("Segoe UI", "bold".equalsIgnoreCase(fw) ? FontWeight.BOLD : FontWeight.NORMAL, fs));
|
||||
if (el.hasAttribute("fill")) {
|
||||
t.setFill(parseColor(el.getAttribute("fill"), Color.BLACK));
|
||||
}
|
||||
if ("middle".equalsIgnoreCase(el.getAttribute("text-anchor"))) {
|
||||
t.setTextOrigin(javafx.geometry.VPos.CENTER);
|
||||
}
|
||||
return t;
|
||||
}
|
||||
case "g" -> {
|
||||
Group g = new Group();
|
||||
renderChildren(el, g);
|
||||
if (el.hasAttribute("stroke")) {
|
||||
Color strokeCol = parseColor(el.getAttribute("stroke"), null);
|
||||
double sw = parseDouble(el.getAttribute("stroke-width"), 1.0);
|
||||
for (Node child : g.getChildren()) {
|
||||
if (child instanceof Shape s) {
|
||||
if (strokeCol != null) s.setStroke(strokeCol);
|
||||
s.setStrokeWidth(sw);
|
||||
}
|
||||
}
|
||||
}
|
||||
return g;
|
||||
}
|
||||
}
|
||||
|
||||
if (shape != null) {
|
||||
applyShapeStyles(el, shape);
|
||||
return shape;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void applyShapeStyles(Element el, Shape shape) {
|
||||
if (el.hasAttribute("fill")) {
|
||||
shape.setFill(parseColor(el.getAttribute("fill"), Color.TRANSPARENT));
|
||||
} else if (shape instanceof Line) {
|
||||
shape.setFill(Color.TRANSPARENT);
|
||||
}
|
||||
|
||||
if (el.hasAttribute("stroke")) {
|
||||
shape.setStroke(parseColor(el.getAttribute("stroke"), Color.TRANSPARENT));
|
||||
}
|
||||
|
||||
if (el.hasAttribute("stroke-width")) {
|
||||
shape.setStrokeWidth(parseDouble(el.getAttribute("stroke-width"), 1.0));
|
||||
}
|
||||
|
||||
if (el.hasAttribute("fill-rule") && shape instanceof SVGPath sp) {
|
||||
if ("evenodd".equalsIgnoreCase(el.getAttribute("fill-rule"))) {
|
||||
sp.setFillRule(javafx.scene.shape.FillRule.EVEN_ODD);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void parsePoints(String ptsStr, java.util.List<Double> pointsList) {
|
||||
String[] tokens = ptsStr.trim().split("[,\\s]+");
|
||||
for (String t : tokens) {
|
||||
if (!t.isBlank()) {
|
||||
try {
|
||||
pointsList.add(Double.parseDouble(t));
|
||||
} catch (NumberFormatException ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Color parseColor(String str, Color fallback) {
|
||||
if (str == null || str.isBlank() || "none".equalsIgnoreCase(str)) {
|
||||
return Color.TRANSPARENT;
|
||||
}
|
||||
String trimmed = str.trim();
|
||||
if ("currentColor".equalsIgnoreCase(trimmed)) {
|
||||
return Color.web("#0288d1");
|
||||
}
|
||||
try {
|
||||
return Color.web(trimmed);
|
||||
} catch (Exception e) {
|
||||
return fallback != null ? fallback : Color.BLACK;
|
||||
}
|
||||
}
|
||||
|
||||
private static double parseDouble(String str, double defaultValue) {
|
||||
if (str == null || str.isBlank()) {
|
||||
return defaultValue;
|
||||
}
|
||||
try {
|
||||
return Double.parseDouble(str.replaceAll("[^0-9.-]", ""));
|
||||
} catch (Exception e) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
package br.com.cybercore.signer.ui.components;
|
||||
|
||||
import br.com.cybercore.signer.core.domain.CertificateStatus;
|
||||
import br.com.cybercore.signer.core.domain.CertificateType;
|
||||
import br.com.cybercore.signer.core.domain.ManagedCertificate;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.VBox;
|
||||
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
/**
|
||||
* Componente visual reutilizável para exibição de um certificado digital.
|
||||
*/
|
||||
@SuppressWarnings("this-escape")
|
||||
public final class CertificateCardNode extends VBox {
|
||||
|
||||
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("dd/MM/yyyy")
|
||||
.withZone(ZoneId.systemDefault());
|
||||
|
||||
private final ManagedCertificate certificate;
|
||||
|
||||
public CertificateCardNode(ManagedCertificate certificate) {
|
||||
this.certificate = certificate;
|
||||
initLayout();
|
||||
}
|
||||
|
||||
private void initLayout() {
|
||||
getStyleClass().add("cert-card");
|
||||
setCursor(javafx.scene.Cursor.HAND);
|
||||
setSpacing(8);
|
||||
|
||||
// Linha 1: Nome do Titular e Badge A1/A3
|
||||
HBox topRow = new HBox(10);
|
||||
topRow.setAlignment(Pos.CENTER_LEFT);
|
||||
|
||||
Label titleLabel = new Label(certificate.commonName() != null ? certificate.commonName() : certificate.alias());
|
||||
titleLabel.getStyleClass().add("cert-title");
|
||||
HBox.setHgrow(titleLabel, Priority.ALWAYS);
|
||||
|
||||
Label badgeLabel = new Label();
|
||||
if (certificate.type() == CertificateType.A1) {
|
||||
badgeLabel.setText("A1 • Certificado local");
|
||||
badgeLabel.getStyleClass().add("cert-badge-a1");
|
||||
} else {
|
||||
String tokenDesc = certificate.hardwareDeviceName() != null ? certificate.hardwareDeviceName() : "Token USB";
|
||||
badgeLabel.setText("A3 • " + tokenDesc);
|
||||
badgeLabel.getStyleClass().add("cert-badge-a3");
|
||||
}
|
||||
|
||||
topRow.getChildren().addAll(titleLabel, badgeLabel);
|
||||
|
||||
// Linha 2: Documento (CPF / CNPJ) e Emissor
|
||||
HBox midRow = new HBox(12);
|
||||
midRow.setAlignment(Pos.CENTER_LEFT);
|
||||
|
||||
if (certificate.cpf() != null) {
|
||||
Label docLabel = new Label("CPF: " + formatCpf(certificate.cpf()));
|
||||
docLabel.getStyleClass().add("cert-detail");
|
||||
midRow.getChildren().add(docLabel);
|
||||
} else if (certificate.cnpj() != null) {
|
||||
Label docLabel = new Label("CNPJ: " + formatCnpj(certificate.cnpj()));
|
||||
docLabel.getStyleClass().add("cert-detail");
|
||||
midRow.getChildren().add(docLabel);
|
||||
}
|
||||
|
||||
String issuerCn = extractIssuerCn(certificate.issuerDn());
|
||||
Label issuerLabel = new Label("Emissor: " + issuerCn);
|
||||
issuerLabel.getStyleClass().add("cert-detail");
|
||||
midRow.getChildren().add(issuerLabel);
|
||||
|
||||
// Linha 3: Validade e Status de disponibilidade
|
||||
HBox bottomRow = new HBox(12);
|
||||
bottomRow.setAlignment(Pos.CENTER_LEFT);
|
||||
|
||||
String validityText = "Válido até " + (certificate.notAfter() != null ? DATE_FORMATTER.format(certificate.notAfter()) : "N/D");
|
||||
Label validityLabel = new Label(validityText);
|
||||
validityLabel.getStyleClass().add("cert-detail");
|
||||
HBox.setHgrow(validityLabel, Priority.ALWAYS);
|
||||
|
||||
Label statusLabel = new Label();
|
||||
if (certificate.status() == CertificateStatus.AVAILABLE && certificate.isCurrentlyValid()) {
|
||||
statusLabel.setText("● Disponível");
|
||||
statusLabel.getStyleClass().add("cert-status-available");
|
||||
} else {
|
||||
statusLabel.setText("● " + certificate.status().getDescription());
|
||||
statusLabel.getStyleClass().add("cert-status-expired");
|
||||
}
|
||||
|
||||
bottomRow.getChildren().addAll(validityLabel, statusLabel);
|
||||
|
||||
getChildren().addAll(topRow, midRow, bottomRow);
|
||||
}
|
||||
|
||||
public ManagedCertificate getCertificate() {
|
||||
return certificate;
|
||||
}
|
||||
|
||||
private String formatCpf(String cpf) {
|
||||
if (cpf != null && cpf.length() == 11) {
|
||||
return cpf.substring(0, 3) + "." + cpf.substring(3, 6) + "." + cpf.substring(6, 9) + "-" + cpf.substring(9);
|
||||
}
|
||||
return cpf;
|
||||
}
|
||||
|
||||
private String formatCnpj(String cnpj) {
|
||||
if (cnpj != null && cnpj.length() == 14) {
|
||||
return cnpj.substring(0, 2) + "." + cnpj.substring(2, 5) + "." + cnpj.substring(5, 8) + "/" + cnpj.substring(8, 12) + "-" + cnpj.substring(12);
|
||||
}
|
||||
return cnpj;
|
||||
}
|
||||
|
||||
private String extractIssuerCn(String dn) {
|
||||
if (dn == null) return "Desconhecido";
|
||||
int idx = dn.indexOf("CN=");
|
||||
if (idx >= 0) {
|
||||
int end = dn.indexOf(',', idx);
|
||||
if (end > idx) {
|
||||
return dn.substring(idx + 3, end).trim();
|
||||
}
|
||||
return dn.substring(idx + 3).trim();
|
||||
}
|
||||
return dn;
|
||||
}
|
||||
}
|
||||
+732
@@ -0,0 +1,732 @@
|
||||
package br.com.cybercore.signer.ui.components;
|
||||
|
||||
import br.com.cybercore.signer.ui.assets.AssetManager;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Group;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.effect.DropShadow;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.scene.paint.Color;
|
||||
import javafx.scene.paint.CycleMethod;
|
||||
import javafx.scene.paint.LinearGradient;
|
||||
import javafx.scene.paint.Stop;
|
||||
import javafx.scene.shape.Circle;
|
||||
import javafx.scene.shape.Polygon;
|
||||
import javafx.scene.shape.Rectangle;
|
||||
import javafx.scene.shape.SVGPath;
|
||||
import javafx.scene.text.Font;
|
||||
import javafx.scene.text.FontWeight;
|
||||
import javafx.scene.text.Text;
|
||||
|
||||
/**
|
||||
* Fábrica de ícones e grafismos vetoriais SVG nítidos e escaláveis para a interface Serpro do CyberCore Signer.
|
||||
* Integra-se ao AssetManager para carregar dinamicamente arquivos .svg ou .png da pasta 'assets/icons/'.
|
||||
*/
|
||||
public final class SvgIcons {
|
||||
|
||||
private SvgIcons() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Logotipo central de cristal/diamante azul tridimensional.
|
||||
*/
|
||||
public static Node createAppCrystalLogo(double size) {
|
||||
return AssetManager.getIconOrDefault("app-icon", size, () -> buildAppCrystalLogo(size));
|
||||
}
|
||||
|
||||
public static Node buildAppCrystalLogo(double size) {
|
||||
StackPane root = new StackPane();
|
||||
root.setPrefSize(size, size);
|
||||
root.setMaxSize(size, size);
|
||||
|
||||
Rectangle bg = new Rectangle(size, size);
|
||||
bg.setArcWidth(size * 0.35);
|
||||
bg.setArcHeight(size * 0.35);
|
||||
LinearGradient bgGrad = new LinearGradient(
|
||||
0, 0, 1, 1, true, CycleMethod.NO_CYCLE,
|
||||
new Stop(0, Color.web("#1976d2")),
|
||||
new Stop(0.5, Color.web("#0d47a1")),
|
||||
new Stop(1, Color.web("#01579b"))
|
||||
);
|
||||
bg.setFill(bgGrad);
|
||||
|
||||
double s = size;
|
||||
Polygon facetTop = new Polygon(
|
||||
s * 0.5, s * 0.15,
|
||||
s * 0.82, s * 0.42,
|
||||
s * 0.5, s * 0.55,
|
||||
s * 0.18, s * 0.42
|
||||
);
|
||||
facetTop.setFill(new LinearGradient(
|
||||
0, 0, 0, 1, true, CycleMethod.NO_CYCLE,
|
||||
new Stop(0, Color.web("#ffffff", 0.85)),
|
||||
new Stop(1, Color.web("#80d8ff", 0.4))
|
||||
));
|
||||
|
||||
Polygon facetBottomLeft = new Polygon(
|
||||
s * 0.18, s * 0.42,
|
||||
s * 0.5, s * 0.55,
|
||||
s * 0.5, s * 0.85,
|
||||
s * 0.28, s * 0.70
|
||||
);
|
||||
facetBottomLeft.setFill(new LinearGradient(
|
||||
0, 0, 1, 1, true, CycleMethod.NO_CYCLE,
|
||||
new Stop(0, Color.web("#40c4ff", 0.6)),
|
||||
new Stop(1, Color.web("#0288d1", 0.2))
|
||||
));
|
||||
|
||||
Polygon facetBottomRight = new Polygon(
|
||||
s * 0.82, s * 0.42,
|
||||
s * 0.5, s * 0.55,
|
||||
s * 0.5, s * 0.85,
|
||||
s * 0.72, s * 0.70
|
||||
);
|
||||
facetBottomRight.setFill(new LinearGradient(
|
||||
1, 0, 0, 1, true, CycleMethod.NO_CYCLE,
|
||||
new Stop(0, Color.web("#e1f5fe", 0.75)),
|
||||
new Stop(1, Color.web("#29b6f6", 0.3))
|
||||
));
|
||||
|
||||
Circle glow = new Circle(s * 0.5, s * 0.5, s * 0.12);
|
||||
glow.setFill(new LinearGradient(
|
||||
0, 0, 1, 1, true, CycleMethod.NO_CYCLE,
|
||||
new Stop(0, Color.web("#ffffff", 0.9)),
|
||||
new Stop(1, Color.web("#ffffff", 0.0))
|
||||
));
|
||||
|
||||
Group crystal = new Group(bg, facetTop, facetBottomLeft, facetBottomRight, glow);
|
||||
DropShadow shadow = new DropShadow();
|
||||
shadow.setColor(Color.web("#0d47a1", 0.4));
|
||||
shadow.setRadius(12);
|
||||
shadow.setOffsetY(4);
|
||||
crystal.setEffect(shadow);
|
||||
|
||||
root.getChildren().add(crystal);
|
||||
return root;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ícone de Assinar PDF.
|
||||
*/
|
||||
public static Node pdfIcon() {
|
||||
return AssetManager.getIconOrDefault("pdf", 38, SvgIcons::buildPdfIcon);
|
||||
}
|
||||
|
||||
public static Node buildPdfIcon() {
|
||||
StackPane box = new StackPane();
|
||||
box.setPrefSize(38, 38);
|
||||
|
||||
SVGPath doc = new SVGPath();
|
||||
doc.setContent("M 6 2 L 24 2 L 32 10 L 32 36 L 6 36 Z");
|
||||
doc.setFill(Color.web("#ffffff"));
|
||||
doc.setStroke(Color.web("#d32f2f"));
|
||||
doc.setStrokeWidth(1.8);
|
||||
|
||||
SVGPath fold = new SVGPath();
|
||||
fold.setContent("M 24 2 L 24 10 L 32 10 Z");
|
||||
fold.setFill(Color.web("#ffcdd2"));
|
||||
fold.setStroke(Color.web("#d32f2f"));
|
||||
fold.setStrokeWidth(1.2);
|
||||
|
||||
Rectangle tag = new Rectangle(20, 10);
|
||||
tag.setArcWidth(4);
|
||||
tag.setArcHeight(4);
|
||||
tag.setFill(Color.web("#e53935"));
|
||||
tag.setTranslateY(4);
|
||||
|
||||
Text txt = new Text("PDF");
|
||||
txt.setFont(Font.font("Segoe UI", FontWeight.BOLD, 7.5));
|
||||
txt.setFill(Color.WHITE);
|
||||
txt.setTranslateY(4);
|
||||
|
||||
box.getChildren().addAll(doc, fold, tag, txt);
|
||||
return box;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ícone de Assinar XML.
|
||||
*/
|
||||
public static Node xmlIcon() {
|
||||
return AssetManager.getIconOrDefault("xml", 38, SvgIcons::buildXmlIcon);
|
||||
}
|
||||
|
||||
public static Node buildXmlIcon() {
|
||||
StackPane box = new StackPane();
|
||||
box.setPrefSize(38, 38);
|
||||
|
||||
SVGPath doc = new SVGPath();
|
||||
doc.setContent("M 6 2 L 24 2 L 32 10 L 32 36 L 6 36 Z");
|
||||
doc.setFill(Color.web("#ffffff"));
|
||||
doc.setStroke(Color.web("#1976d2"));
|
||||
doc.setStrokeWidth(1.8);
|
||||
|
||||
SVGPath fold = new SVGPath();
|
||||
fold.setContent("M 24 2 L 24 10 L 32 10 Z");
|
||||
fold.setFill(Color.web("#bbdefb"));
|
||||
fold.setStroke(Color.web("#1976d2"));
|
||||
fold.setStrokeWidth(1.2);
|
||||
|
||||
Rectangle tag = new Rectangle(22, 10);
|
||||
tag.setArcWidth(4);
|
||||
tag.setArcHeight(4);
|
||||
tag.setFill(Color.web("#0288d1"));
|
||||
tag.setTranslateY(4);
|
||||
|
||||
Text txt = new Text("<XML>");
|
||||
txt.setFont(Font.font("Segoe UI", FontWeight.BOLD, 6.8));
|
||||
txt.setFill(Color.WHITE);
|
||||
txt.setTranslateY(4);
|
||||
|
||||
box.getChildren().addAll(doc, fold, tag, txt);
|
||||
return box;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ícone de Assinar Arquivo genérico.
|
||||
*/
|
||||
public static Node fileIcon() {
|
||||
return AssetManager.getIconOrDefault("file", 38, SvgIcons::buildFileIcon);
|
||||
}
|
||||
|
||||
public static Node buildFileIcon() {
|
||||
StackPane box = new StackPane();
|
||||
box.setPrefSize(38, 38);
|
||||
|
||||
SVGPath doc = new SVGPath();
|
||||
doc.setContent("M 6 2 L 24 2 L 32 10 L 32 36 L 6 36 Z");
|
||||
doc.setFill(Color.web("#ffffff"));
|
||||
doc.setStroke(Color.web("#0288d1"));
|
||||
doc.setStrokeWidth(1.8);
|
||||
|
||||
SVGPath fold = new SVGPath();
|
||||
fold.setContent("M 24 2 L 24 10 L 32 10 Z");
|
||||
fold.setFill(Color.web("#b3e5fc"));
|
||||
fold.setStroke(Color.web("#0288d1"));
|
||||
fold.setStrokeWidth(1.2);
|
||||
|
||||
SVGPath pen = new SVGPath();
|
||||
pen.setContent("M 10 24 L 22 12 L 26 16 L 14 28 L 8 29 Z");
|
||||
pen.setFill(Color.web("#0288d1"));
|
||||
|
||||
box.getChildren().addAll(doc, fold, pen);
|
||||
return box;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ícone de Assinar em Lote.
|
||||
*/
|
||||
public static Node batchIcon() {
|
||||
return AssetManager.getIconOrDefault("batch", 38, SvgIcons::buildBatchIcon);
|
||||
}
|
||||
|
||||
public static Node buildBatchIcon() {
|
||||
StackPane box = new StackPane();
|
||||
box.setPrefSize(38, 38);
|
||||
|
||||
SVGPath doc3 = new SVGPath();
|
||||
doc3.setContent("M 12 2 L 30 2 L 30 26 L 12 26 Z");
|
||||
doc3.setFill(Color.web("#e1f5fe"));
|
||||
doc3.setStroke(Color.web("#4fc3f7"));
|
||||
doc3.setStrokeWidth(1.2);
|
||||
doc3.setTranslateX(4);
|
||||
doc3.setTranslateY(-4);
|
||||
|
||||
SVGPath doc2 = new SVGPath();
|
||||
doc2.setContent("M 8 6 L 28 6 L 28 30 L 8 30 Z");
|
||||
doc2.setFill(Color.web("#b3e5fc"));
|
||||
doc2.setStroke(Color.web("#0288d1"));
|
||||
doc2.setStrokeWidth(1.4);
|
||||
doc2.setTranslateX(2);
|
||||
doc2.setTranslateY(-2);
|
||||
|
||||
SVGPath doc1 = new SVGPath();
|
||||
doc1.setContent("M 4 10 L 24 10 L 24 34 L 4 34 Z");
|
||||
doc1.setFill(Color.web("#ffffff"));
|
||||
doc1.setStroke(Color.web("#01579b"));
|
||||
doc1.setStrokeWidth(1.6);
|
||||
|
||||
SVGPath check = new SVGPath();
|
||||
check.setContent("M 8 22 L 13 27 L 21 16");
|
||||
check.setStroke(Color.web("#00c853"));
|
||||
check.setStrokeWidth(2.2);
|
||||
check.setFill(Color.TRANSPARENT);
|
||||
|
||||
box.getChildren().addAll(doc3, doc2, doc1, check);
|
||||
return box;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ícone de Assinatura Atachada.
|
||||
*/
|
||||
public static Node attachedIcon() {
|
||||
return AssetManager.getIconOrDefault("attached", 38, SvgIcons::buildAttachedIcon);
|
||||
}
|
||||
|
||||
public static Node buildAttachedIcon() {
|
||||
StackPane box = new StackPane();
|
||||
box.setPrefSize(38, 38);
|
||||
|
||||
SVGPath doc = new SVGPath();
|
||||
doc.setContent("M 8 4 L 28 4 L 28 34 L 8 34 Z");
|
||||
doc.setFill(Color.web("#ffffff"));
|
||||
doc.setStroke(Color.web("#0288d1"));
|
||||
doc.setStrokeWidth(1.6);
|
||||
|
||||
SVGPath lines = new SVGPath();
|
||||
lines.setContent("M 12 12 L 24 12 M 12 17 L 24 17 M 12 22 L 20 22");
|
||||
lines.setStroke(Color.web("#90caf9"));
|
||||
lines.setStrokeWidth(1.4);
|
||||
|
||||
SVGPath clip = new SVGPath();
|
||||
clip.setContent("M 10 2 C 7 2 6 5 6 8 L 6 20 C 6 23 8 25 11 25 C 14 25 16 23 16 20 L 16 9 C 16 7 15 6 13 6 C 11 6 10 7 10 9 L 10 19");
|
||||
clip.setFill(Color.TRANSPARENT);
|
||||
clip.setStroke(Color.web("#546e7a"));
|
||||
clip.setStrokeWidth(1.8);
|
||||
clip.setTranslateX(-8);
|
||||
clip.setTranslateY(-4);
|
||||
|
||||
box.getChildren().addAll(doc, lines, clip);
|
||||
return box;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ícone de Co-Assinar.
|
||||
*/
|
||||
public static Node coSignIcon() {
|
||||
return AssetManager.getIconOrDefault("co-sign", 38, SvgIcons::buildCoSignIcon);
|
||||
}
|
||||
|
||||
public static Node buildCoSignIcon() {
|
||||
StackPane box = new StackPane();
|
||||
box.setPrefSize(38, 38);
|
||||
|
||||
SVGPath doc1 = new SVGPath();
|
||||
doc1.setContent("M 4 8 L 22 8 L 22 32 L 4 32 Z");
|
||||
doc1.setFill(Color.web("#ffffff"));
|
||||
doc1.setStroke(Color.web("#1976d2"));
|
||||
doc1.setStrokeWidth(1.5);
|
||||
|
||||
SVGPath doc2 = new SVGPath();
|
||||
doc2.setContent("M 14 4 L 32 4 L 32 28 L 14 28 Z");
|
||||
doc2.setFill(Color.web("#e3f2fd"));
|
||||
doc2.setStroke(Color.web("#0288d1"));
|
||||
doc2.setStrokeWidth(1.5);
|
||||
|
||||
SVGPath people = new SVGPath();
|
||||
people.setContent("M 10 26 C 10 22 14 22 16 22 C 18 22 22 22 22 26 M 16 17 C 14.5 17 14.5 14 16 14 C 17.5 14 17.5 17 16 17");
|
||||
people.setStroke(Color.web("#0d47a1"));
|
||||
people.setStrokeWidth(1.6);
|
||||
people.setFill(Color.TRANSPARENT);
|
||||
|
||||
box.getChildren().addAll(doc2, doc1, people);
|
||||
return box;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ícone de Carimbo do Tempo.
|
||||
*/
|
||||
public static Node timestampIcon() {
|
||||
return AssetManager.getIconOrDefault("timestamp", 38, SvgIcons::buildTimestampIcon);
|
||||
}
|
||||
|
||||
public static Node buildTimestampIcon() {
|
||||
StackPane box = new StackPane();
|
||||
box.setPrefSize(38, 38);
|
||||
|
||||
SVGPath doc = new SVGPath();
|
||||
doc.setContent("M 6 2 L 24 2 L 32 10 L 32 34 L 6 34 Z");
|
||||
doc.setFill(Color.web("#ffffff"));
|
||||
doc.setStroke(Color.web("#0288d1"));
|
||||
doc.setStrokeWidth(1.5);
|
||||
|
||||
Circle clockBg = new Circle(18, 24, 9);
|
||||
clockBg.setFill(Color.web("#e1f5fe"));
|
||||
clockBg.setStroke(Color.web("#0288d1"));
|
||||
clockBg.setStrokeWidth(1.6);
|
||||
|
||||
SVGPath clockHands = new SVGPath();
|
||||
clockHands.setContent("M 18 19 L 18 24 L 22 24");
|
||||
clockHands.setStroke(Color.web("#01579b"));
|
||||
clockHands.setStrokeWidth(1.6);
|
||||
clockHands.setFill(Color.TRANSPARENT);
|
||||
|
||||
box.getChildren().addAll(doc, clockBg, clockHands);
|
||||
return box;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ícone de Assinatura de Longa Duração (LTV).
|
||||
*/
|
||||
public static Node ltvIcon() {
|
||||
return AssetManager.getIconOrDefault("ltv", 38, SvgIcons::buildLtvIcon);
|
||||
}
|
||||
|
||||
public static Node buildLtvIcon() {
|
||||
StackPane box = new StackPane();
|
||||
box.setPrefSize(38, 38);
|
||||
|
||||
SVGPath doc = new SVGPath();
|
||||
doc.setContent("M 6 2 L 24 2 L 32 10 L 32 34 L 6 34 Z");
|
||||
doc.setFill(Color.web("#ffffff"));
|
||||
doc.setStroke(Color.web("#00838f"));
|
||||
doc.setStrokeWidth(1.5);
|
||||
|
||||
Circle shield = new Circle(20, 24, 8);
|
||||
shield.setFill(Color.web("#e0f2f1"));
|
||||
shield.setStroke(Color.web("#00897b"));
|
||||
shield.setStrokeWidth(1.6);
|
||||
|
||||
Text ltv = new Text("LTV");
|
||||
ltv.setFont(Font.font("Segoe UI", FontWeight.BOLD, 6.8));
|
||||
ltv.setFill(Color.web("#00695c"));
|
||||
ltv.setTranslateX(2);
|
||||
ltv.setTranslateY(6);
|
||||
|
||||
box.getChildren().addAll(doc, shield, ltv);
|
||||
return box;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ícone de Validar Assinatura.
|
||||
*/
|
||||
public static Node validateIcon() {
|
||||
return AssetManager.getIconOrDefault("validate", 38, SvgIcons::buildValidateIcon);
|
||||
}
|
||||
|
||||
public static Node buildValidateIcon() {
|
||||
StackPane box = new StackPane();
|
||||
box.setPrefSize(38, 38);
|
||||
|
||||
SVGPath doc = new SVGPath();
|
||||
doc.setContent("M 6 4 L 26 4 L 26 34 L 6 34 Z");
|
||||
doc.setFill(Color.web("#ffffff"));
|
||||
doc.setStroke(Color.web("#0288d1"));
|
||||
doc.setStrokeWidth(1.5);
|
||||
|
||||
SVGPath check = new SVGPath();
|
||||
check.setContent("M 8 20 L 16 28 L 32 10");
|
||||
check.setStroke(Color.web("#2e7d32"));
|
||||
check.setStrokeWidth(2.6);
|
||||
check.setFill(Color.TRANSPARENT);
|
||||
|
||||
box.getChildren().addAll(doc, check);
|
||||
return box;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ícone de Validar Carimbo do Tempo.
|
||||
*/
|
||||
public static Node validateTimestampIcon() {
|
||||
return AssetManager.getIconOrDefault("validate-timestamp", 38, SvgIcons::buildValidateTimestampIcon);
|
||||
}
|
||||
|
||||
public static Node buildValidateTimestampIcon() {
|
||||
StackPane box = new StackPane();
|
||||
box.setPrefSize(38, 38);
|
||||
|
||||
Circle clock = new Circle(16, 16, 12);
|
||||
clock.setFill(Color.web("#f1f8e9"));
|
||||
clock.setStroke(Color.web("#558b2f"));
|
||||
clock.setStrokeWidth(1.6);
|
||||
|
||||
SVGPath check = new SVGPath();
|
||||
check.setContent("M 10 16 L 15 21 L 24 11");
|
||||
check.setStroke(Color.web("#2e7d32"));
|
||||
check.setStrokeWidth(2.2);
|
||||
check.setFill(Color.TRANSPARENT);
|
||||
|
||||
box.getChildren().addAll(clock, check);
|
||||
return box;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ícone de Sair do Agente.
|
||||
*/
|
||||
public static Node exitIcon() {
|
||||
return AssetManager.getIconOrDefault("exit", 38, SvgIcons::buildExitIcon);
|
||||
}
|
||||
|
||||
public static Node buildExitIcon() {
|
||||
StackPane box = new StackPane();
|
||||
box.setPrefSize(38, 38);
|
||||
|
||||
SVGPath door = new SVGPath();
|
||||
door.setContent("M 18 6 L 8 6 C 6.5 6 6 7.5 6 9 L 6 27 C 6 28.5 6.5 30 8 30 L 18 30");
|
||||
door.setStroke(Color.web("#546e7a"));
|
||||
door.setStrokeWidth(2);
|
||||
door.setFill(Color.TRANSPARENT);
|
||||
|
||||
SVGPath arrow = new SVGPath();
|
||||
arrow.setContent("M 14 18 L 30 18 M 24 12 L 30 18 L 24 24");
|
||||
arrow.setStroke(Color.web("#d32f2f"));
|
||||
arrow.setStrokeWidth(2);
|
||||
arrow.setFill(Color.TRANSPARENT);
|
||||
|
||||
box.getChildren().addAll(door, arrow);
|
||||
return box;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ícone de Logs.
|
||||
*/
|
||||
public static Node logsIcon() {
|
||||
return AssetManager.getIconOrDefault("logs", 24, SvgIcons::buildLogsIcon);
|
||||
}
|
||||
|
||||
public static Node buildLogsIcon() {
|
||||
StackPane box = new StackPane();
|
||||
box.setPrefSize(24, 24);
|
||||
|
||||
Text code = new Text("</>");
|
||||
code.setFont(Font.font("Segoe UI", FontWeight.BOLD, 12));
|
||||
code.setFill(Color.web("#0288d1"));
|
||||
|
||||
box.getChildren().add(code);
|
||||
return box;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ícone de Configurações.
|
||||
*/
|
||||
public static Node settingsIcon() {
|
||||
return AssetManager.getIconOrDefault("settings", 24, SvgIcons::buildSettingsIcon);
|
||||
}
|
||||
|
||||
public static Node buildSettingsIcon() {
|
||||
StackPane box = new StackPane();
|
||||
box.setPrefSize(24, 24);
|
||||
|
||||
SVGPath gear = new SVGPath();
|
||||
gear.setContent("M 12 1 C 11 1 10.5 2 10.5 3 C 9.5 3.3 8.6 3.8 7.8 4.5 L 6.2 3.6 C 5.5 3.2 4.6 3.5 4.1 4.1 L 2.6 6.6 C 2.1 7.2 2.3 8.1 3 8.6 L 4.4 9.6 C 4.2 10.4 4.1 11.2 4.1 12 C 4.1 12.8 4.2 13.6 4.4 14.4 L 3 15.4 C 2.3 15.9 2.1 16.8 2.6 17.4 L 4.1 19.9 C 4.6 20.5 5.5 20.8 6.2 20.4 L 7.8 19.5 C 8.6 20.2 9.5 20.7 10.5 21 C 10.5 22 11 23 12 23 C 13 23 13.5 22 13.5 21 C 14.5 20.7 15.4 20.2 16.2 19.5 L 17.8 20.4 C 18.5 20.8 19.4 20.5 19.9 19.9 L 21.4 17.4 C 21.9 16.8 21.7 15.9 21 15.4 L 19.6 14.4 C 19.8 13.6 19.9 12.8 19.9 12 C 19.9 11.2 19.8 10.4 19.6 9.6 L 21 8.6 C 21.7 8.1 21.9 7.2 21.4 6.6 L 19.9 4.1 C 19.4 3.5 18.5 3.2 17.8 3.6 L 16.2 4.5 C 15.4 3.8 14.5 3.3 13.5 3 C 13.5 2 13 1 12 1 Z M 12 8 C 14.2 8 16 9.8 16 12 C 16 14.2 14.2 16 12 16 C 9.8 16 8 14.2 8 12 C 8 9.8 9.8 8 12 8 Z");
|
||||
gear.setFill(Color.web("#0288d1"));
|
||||
gear.setScaleX(0.85);
|
||||
gear.setScaleY(0.85);
|
||||
|
||||
box.getChildren().add(gear);
|
||||
return box;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ícone de Tutorial.
|
||||
*/
|
||||
public static Node tutorialIcon() {
|
||||
return AssetManager.getIconOrDefault("tutorial", 24, SvgIcons::buildTutorialIcon);
|
||||
}
|
||||
|
||||
public static Node buildTutorialIcon() {
|
||||
StackPane box = new StackPane();
|
||||
box.setPrefSize(24, 24);
|
||||
|
||||
SVGPath monitor = new SVGPath();
|
||||
monitor.setContent("M 2 4 L 22 4 L 22 17 L 2 17 Z M 8 17 L 6 21 L 18 21 L 16 17 M 12 17 L 12 21");
|
||||
monitor.setStroke(Color.web("#0288d1"));
|
||||
monitor.setStrokeWidth(1.6);
|
||||
monitor.setFill(Color.TRANSPARENT);
|
||||
|
||||
box.getChildren().add(monitor);
|
||||
return box;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ícone de Certificados.
|
||||
*/
|
||||
public static Node certificatesIcon() {
|
||||
return AssetManager.getIconOrDefault("cert", 24, SvgIcons::buildCertificatesIcon);
|
||||
}
|
||||
|
||||
public static Node buildCertificatesIcon() {
|
||||
StackPane box = new StackPane();
|
||||
box.setPrefSize(24, 24);
|
||||
|
||||
SVGPath folder = new SVGPath();
|
||||
folder.setContent("M 2 5 L 8 5 L 10 7 L 22 7 L 22 20 L 2 20 Z");
|
||||
folder.setFill(Color.web("#e1f5fe"));
|
||||
folder.setStroke(Color.web("#0288d1"));
|
||||
folder.setStrokeWidth(1.5);
|
||||
|
||||
Rectangle lock = new Rectangle(7, 6);
|
||||
lock.setArcWidth(2);
|
||||
lock.setArcHeight(2);
|
||||
lock.setFill(Color.web("#0288d1"));
|
||||
lock.setTranslateX(3);
|
||||
lock.setTranslateY(2);
|
||||
|
||||
box.getChildren().addAll(folder, lock);
|
||||
return box;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ícone de Sobre.
|
||||
*/
|
||||
public static Node aboutIcon() {
|
||||
return AssetManager.getIconOrDefault("about", 24, SvgIcons::buildAboutIcon);
|
||||
}
|
||||
|
||||
public static Node buildAboutIcon() {
|
||||
StackPane box = new StackPane();
|
||||
box.setPrefSize(24, 24);
|
||||
|
||||
Circle circle = new Circle(10);
|
||||
circle.setFill(Color.web("#0288d1"));
|
||||
|
||||
Text i = new Text("i");
|
||||
i.setFont(Font.font("Segoe UI", FontWeight.BOLD, 13));
|
||||
i.setFill(Color.WHITE);
|
||||
|
||||
box.getChildren().addAll(circle, i);
|
||||
return box;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ícone de Sol (Light Mode).
|
||||
*/
|
||||
public static Node sunIcon(double size) {
|
||||
return AssetManager.getIconOrDefault("sun", size, () -> buildSunIcon(size));
|
||||
}
|
||||
|
||||
public static Node buildSunIcon(double size) {
|
||||
StackPane box = new StackPane();
|
||||
box.setPrefSize(size, size);
|
||||
box.setMaxSize(size, size);
|
||||
|
||||
Circle sunCore = new Circle(size * 0.5, size * 0.5, size * 0.24);
|
||||
sunCore.setFill(new LinearGradient(
|
||||
0, 0, 1, 1, true, CycleMethod.NO_CYCLE,
|
||||
new Stop(0, Color.web("#f59e0b")),
|
||||
new Stop(1, Color.web("#d97706"))
|
||||
));
|
||||
|
||||
SVGPath rays = new SVGPath();
|
||||
rays.setContent("M 12 2 L 12 5 M 12 19 L 12 22 M 2 12 L 5 12 M 19 12 L 22 12 " +
|
||||
"M 4.93 4.93 L 7.05 7.05 M 16.95 16.95 L 19.07 19.07 " +
|
||||
"M 4.93 19.07 L 7.05 16.95 M 16.95 7.05 L 19.07 4.93");
|
||||
rays.setStroke(Color.web("#f59e0b"));
|
||||
rays.setStrokeWidth(2.0);
|
||||
rays.setScaleX(size / 24.0);
|
||||
rays.setScaleY(size / 24.0);
|
||||
|
||||
box.getChildren().addAll(rays, sunCore);
|
||||
return box;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ícone de Lua (Dark Mode).
|
||||
*/
|
||||
public static Node moonIcon(double size) {
|
||||
return AssetManager.getIconOrDefault("moon", size, () -> buildMoonIcon(size));
|
||||
}
|
||||
|
||||
public static Node buildMoonIcon(double size) {
|
||||
StackPane box = new StackPane();
|
||||
box.setPrefSize(size, size);
|
||||
box.setMaxSize(size, size);
|
||||
|
||||
SVGPath moon = new SVGPath();
|
||||
moon.setContent("M 21 12.79 A 9 9 0 1 1 11.21 3 A 7 7 0 0 0 21 12.79 Z");
|
||||
moon.setFill(new LinearGradient(
|
||||
0, 0, 1, 1, true, CycleMethod.NO_CYCLE,
|
||||
new Stop(0, Color.web("#38bdf8")),
|
||||
new Stop(1, Color.web("#818cf8"))
|
||||
));
|
||||
moon.setScaleX(size / 24.0);
|
||||
moon.setScaleY(size / 24.0);
|
||||
|
||||
box.getChildren().add(moon);
|
||||
return box;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logotipo vetorial CyberShield.
|
||||
*/
|
||||
public static Node cyberShieldLogo(double size) {
|
||||
return AssetManager.getIconOrDefault("shield", size, () -> buildCyberShieldLogo(size));
|
||||
}
|
||||
|
||||
public static Node buildCyberShieldLogo(double size) {
|
||||
StackPane box = new StackPane();
|
||||
box.setPrefSize(size, size);
|
||||
box.setMaxSize(size, size);
|
||||
|
||||
SVGPath shield = new SVGPath();
|
||||
shield.setContent("M 12 2 L 3 6 L 3 13 C 3 18.5 7 23.5 12 25 C 17 23.5 21 18.5 21 13 L 21 6 Z");
|
||||
shield.setFill(new LinearGradient(
|
||||
0, 0, 1, 1, true, CycleMethod.NO_CYCLE,
|
||||
new Stop(0, Color.web("#0284c7")),
|
||||
new Stop(0.5, Color.web("#0369a1")),
|
||||
new Stop(1, Color.web("#075985"))
|
||||
));
|
||||
shield.setStroke(Color.web("#38bdf8"));
|
||||
shield.setStrokeWidth(1.2);
|
||||
shield.setScaleX(size / 26.0);
|
||||
shield.setScaleY(size / 26.0);
|
||||
|
||||
SVGPath key = new SVGPath();
|
||||
key.setContent("M 12 7 C 10.3 7 9 8.3 9 10 C 9 11.3 9.8 12.4 11 12.8 L 11 18 L 13 18 L 13 15 L 14 15 L 14 13 L 13 13 L 13 12.8 C 14.2 12.4 15 11.3 15 10 C 15 8.3 13.7 7 12 7 Z M 12 9 C 12.6 9 13 9.4 13 10 C 13 10.6 12.6 11 12 11 C 11.4 11 11 10.6 11 10 C 11 9.4 11.4 9 12 9 Z");
|
||||
key.setFill(Color.web("#ffffff"));
|
||||
key.setScaleX(size / 26.0);
|
||||
key.setScaleY(size / 26.0);
|
||||
|
||||
box.getChildren().addAll(shield, key);
|
||||
return box;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ícone de Gerenciador/Criador de Selos Visuais de Assinatura.
|
||||
*/
|
||||
public static Node sealIcon() {
|
||||
return AssetManager.getIconOrDefault("seal", 38, SvgIcons::buildSealIcon);
|
||||
}
|
||||
|
||||
public static Node buildSealIcon() {
|
||||
StackPane box = new StackPane();
|
||||
box.setPrefSize(38, 38);
|
||||
|
||||
SVGPath ribbon = new SVGPath();
|
||||
ribbon.setContent("M 14 26 L 19 36 L 24 26 Z M 9 26 L 12 34 L 15 26 Z");
|
||||
ribbon.setFill(Color.web("#f59e0b"));
|
||||
|
||||
Circle sealCircle = new Circle(13);
|
||||
sealCircle.setFill(Color.web("#d97706"));
|
||||
sealCircle.setStroke(Color.web("#fbbf24"));
|
||||
sealCircle.setStrokeWidth(1.6);
|
||||
sealCircle.setTranslateY(-4);
|
||||
|
||||
SVGPath star = new SVGPath();
|
||||
star.setContent("M 19 8 L 21 13 L 26 13 L 22 16 L 24 21 L 19 18 L 14 21 L 16 16 L 12 13 L 17 13 Z");
|
||||
star.setFill(Color.web("#ffffff"));
|
||||
star.setTranslateX(-9);
|
||||
star.setTranslateY(-14);
|
||||
|
||||
box.getChildren().addAll(ribbon, sealCircle, star);
|
||||
return box;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ícone vetorial de Fechar / Cancelar (cruz geométrica moderna e limpa).
|
||||
*/
|
||||
public static Node closeIcon() {
|
||||
return closeIcon(14);
|
||||
}
|
||||
|
||||
public static Node closeIcon(double size) {
|
||||
StackPane box = new StackPane();
|
||||
box.setPrefSize(size, size);
|
||||
box.setMaxSize(size, size);
|
||||
box.setMinSize(size, size);
|
||||
box.setAlignment(Pos.CENTER);
|
||||
|
||||
SVGPath cross = new SVGPath();
|
||||
cross.setContent("M 3.5 3.5 L 12.5 12.5 M 12.5 3.5 L 3.5 12.5");
|
||||
cross.setStroke(Color.web("#64748b"));
|
||||
cross.setStrokeWidth(2.0);
|
||||
cross.setStrokeLineCap(javafx.scene.shape.StrokeLineCap.ROUND);
|
||||
cross.setFill(Color.TRANSPARENT);
|
||||
|
||||
double scale = size / 16.0;
|
||||
cross.setScaleX(scale);
|
||||
cross.setScaleY(scale);
|
||||
|
||||
box.getChildren().add(cross);
|
||||
return box;
|
||||
}
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
package br.com.cybercore.signer.ui.theme;
|
||||
|
||||
import br.com.cybercore.signer.ui.CyberCoreUiConstants;
|
||||
import javafx.scene.Parent;
|
||||
import javafx.scene.Scene;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Gerenciador centralizado de temas visuais do CyberCore Signer.
|
||||
* Permite alternar dinamicamente entre Tema Claro (Light) e Tema Escuro (Dark)
|
||||
* aplicando as classes CSS e folhas de estilo correspondentes em qualquer Scene.
|
||||
*/
|
||||
public final class ThemeManager {
|
||||
|
||||
public enum AppTheme {
|
||||
LIGHT("theme-light", "Modo Claro"),
|
||||
DARK("theme-dark", "Modo Escuro");
|
||||
|
||||
private final String cssClass;
|
||||
private final String displayName;
|
||||
|
||||
AppTheme(String cssClass, String displayName) {
|
||||
this.cssClass = cssClass;
|
||||
this.displayName = displayName;
|
||||
}
|
||||
|
||||
public String getCssClass() {
|
||||
return cssClass;
|
||||
}
|
||||
|
||||
public String getDisplayName() {
|
||||
return displayName;
|
||||
}
|
||||
}
|
||||
|
||||
private static AppTheme currentTheme = AppTheme.LIGHT;
|
||||
private static final List<Consumer<AppTheme>> listeners = new ArrayList<>();
|
||||
|
||||
private ThemeManager() {
|
||||
}
|
||||
|
||||
public static AppTheme getCurrentTheme() {
|
||||
return currentTheme;
|
||||
}
|
||||
|
||||
public static boolean isDark() {
|
||||
return currentTheme == AppTheme.DARK;
|
||||
}
|
||||
|
||||
public static void setCurrentTheme(AppTheme theme) {
|
||||
if (theme == null || theme == currentTheme) {
|
||||
return;
|
||||
}
|
||||
currentTheme = theme;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
public static AppTheme toggleTheme() {
|
||||
currentTheme = (currentTheme == AppTheme.LIGHT) ? AppTheme.DARK : AppTheme.LIGHT;
|
||||
notifyListeners();
|
||||
return currentTheme;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aplica o tema atualmente ativo a uma Scene, adicionando a folha de estilo global
|
||||
* e ajustando a classe CSS raiz (theme-light / theme-dark).
|
||||
*/
|
||||
public static void applyTheme(Scene scene) {
|
||||
if (scene == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
String cssUrl = Objects.requireNonNull(
|
||||
ThemeManager.class.getResource(CyberCoreUiConstants.MAIN_CSS_PATH)
|
||||
).toExternalForm();
|
||||
|
||||
if (!scene.getStylesheets().contains(cssUrl)) {
|
||||
scene.getStylesheets().add(cssUrl);
|
||||
}
|
||||
|
||||
Parent root = scene.getRoot();
|
||||
if (root != null) {
|
||||
applyThemeToNode(root);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aplica a classe de estilo do tema ativo no nó raiz fornecido.
|
||||
*/
|
||||
public static void applyThemeToNode(Parent node) {
|
||||
if (node == null) {
|
||||
return;
|
||||
}
|
||||
node.getStyleClass().removeAll(AppTheme.LIGHT.getCssClass(), AppTheme.DARK.getCssClass());
|
||||
node.getStyleClass().add(currentTheme.getCssClass());
|
||||
}
|
||||
|
||||
public static void addListener(Consumer<AppTheme> listener) {
|
||||
if (listener != null && !listeners.contains(listener)) {
|
||||
listeners.add(listener);
|
||||
}
|
||||
}
|
||||
|
||||
public static void removeListener(Consumer<AppTheme> listener) {
|
||||
listeners.remove(listener);
|
||||
}
|
||||
|
||||
private static void notifyListeners() {
|
||||
for (Consumer<AppTheme> listener : new ArrayList<>(listeners)) {
|
||||
try {
|
||||
listener.accept(currentTheme);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
package br.com.cybercore.signer.ui.tray;
|
||||
|
||||
import javafx.application.Platform;
|
||||
import javafx.stage.Stage;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.awt.AWTException;
|
||||
import java.awt.Color;
|
||||
import java.awt.Font;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.Image;
|
||||
import java.awt.MenuItem;
|
||||
import java.awt.PopupMenu;
|
||||
import java.awt.RenderingHints;
|
||||
import java.awt.SystemTray;
|
||||
import java.awt.TrayIcon;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Gerencia a integração nativa com o System Tray (Área de Notificação / Bandeja do Windows).
|
||||
* Permite que o CyberCore Signer permaneça em execução transparente em segundo plano,
|
||||
* garantindo o atendimento ininterrupto às requisições do protocolo CCSP/1.
|
||||
*/
|
||||
public class SystemTrayManager {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(SystemTrayManager.class);
|
||||
|
||||
private final Stage stage;
|
||||
private final Runnable onRefreshCertificates;
|
||||
private TrayIcon trayIcon;
|
||||
private boolean initialized = false;
|
||||
|
||||
public SystemTrayManager(Stage stage, Runnable onRefreshCertificates) {
|
||||
this.stage = Objects.requireNonNull(stage, "stage must not be null");
|
||||
this.onRefreshCertificates = onRefreshCertificates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inicializa o ícone e menu de contexto na bandeja do sistema, caso haja suporte.
|
||||
*/
|
||||
public synchronized boolean setup() {
|
||||
if (!SystemTray.isSupported()) {
|
||||
logger.info("SystemTray não é suportado pelo ambiente atual ou sistema operacional.");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
SystemTray tray = SystemTray.getSystemTray();
|
||||
Image iconImage = createTrayIconImage();
|
||||
|
||||
PopupMenu popup = new PopupMenu();
|
||||
|
||||
MenuItem openItem = new MenuItem("Abrir CyberCore Signer");
|
||||
openItem.addActionListener(e -> Platform.runLater(this::showStage));
|
||||
|
||||
MenuItem refreshItem = new MenuItem("Atualizar Certificados");
|
||||
refreshItem.addActionListener(e -> {
|
||||
if (onRefreshCertificates != null) {
|
||||
onRefreshCertificates.run();
|
||||
}
|
||||
});
|
||||
|
||||
MenuItem exitItem = new MenuItem("Encerrar Agente");
|
||||
exitItem.addActionListener(e -> {
|
||||
logger.info("Encerramento solicitado através do menu da bandeja.");
|
||||
shutdown();
|
||||
});
|
||||
|
||||
popup.add(openItem);
|
||||
popup.add(refreshItem);
|
||||
popup.addSeparator();
|
||||
popup.add(exitItem);
|
||||
|
||||
this.trayIcon = new TrayIcon(iconImage, "CyberCore Signer (CCSP/1 Ativo)", popup);
|
||||
this.trayIcon.setImageAutoSize(true);
|
||||
|
||||
// Clique no ícone restaura a janela
|
||||
this.trayIcon.addActionListener(e -> Platform.runLater(this::showStage));
|
||||
|
||||
tray.add(this.trayIcon);
|
||||
this.initialized = true;
|
||||
|
||||
// Intercepta fechamento da janela para ocultar em vez de encerrar
|
||||
Platform.runLater(() -> {
|
||||
stage.setOnCloseRequest(event -> {
|
||||
event.consume();
|
||||
stage.hide();
|
||||
showMessage("CyberCore Signer", "O agente continua ativo na bandeja atendendo aplicações web.", TrayIcon.MessageType.INFO);
|
||||
});
|
||||
});
|
||||
|
||||
logger.info("System Tray configurado com sucesso.");
|
||||
return true;
|
||||
|
||||
} catch (AWTException e) {
|
||||
logger.warn("Falha ao registrar ícone no System Tray: {}", e.getMessage());
|
||||
return false;
|
||||
} catch (Throwable t) {
|
||||
logger.warn("Erro ao inicializar System Tray: {}", t.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void showStage() {
|
||||
if (stage != null) {
|
||||
stage.show();
|
||||
stage.setIconified(false);
|
||||
stage.toFront();
|
||||
}
|
||||
}
|
||||
|
||||
public void showMessage(String title, String message, TrayIcon.MessageType type) {
|
||||
if (initialized && trayIcon != null) {
|
||||
try {
|
||||
trayIcon.displayMessage(title, message, type);
|
||||
} catch (Throwable t) {
|
||||
logger.debug("Não foi possível exibir notificação no Tray: {}", t.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized void remove() {
|
||||
if (initialized && trayIcon != null) {
|
||||
try {
|
||||
SystemTray.getSystemTray().remove(trayIcon);
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
initialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void shutdown() {
|
||||
remove();
|
||||
Platform.runLater(() -> {
|
||||
stage.close();
|
||||
Platform.exit();
|
||||
System.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gera dinamicamente um ícone vetorial de 32x32 com o padrão de cores CyberCore (#38bdf8).
|
||||
*/
|
||||
private Image createTrayIconImage() {
|
||||
int size = 32;
|
||||
BufferedImage image = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB);
|
||||
Graphics2D g = image.createGraphics();
|
||||
|
||||
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
|
||||
// Fundo em círculo azul escuro CyberCore
|
||||
g.setColor(new Color(15, 23, 42));
|
||||
g.fillRoundRect(2, 2, size - 4, size - 4, 8, 8);
|
||||
|
||||
// Borda ciano com gradiente
|
||||
g.setColor(new Color(56, 189, 248));
|
||||
g.drawRoundRect(2, 2, size - 4, size - 4, 8, 8);
|
||||
|
||||
// Letra C central estilizada
|
||||
g.setColor(new Color(56, 189, 248));
|
||||
g.setFont(new Font("SansSerif", Font.BOLD, 18));
|
||||
g.drawString("C", 9, 23);
|
||||
|
||||
g.dispose();
|
||||
return image;
|
||||
}
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
package br.com.cybercore.signer.ui.view;
|
||||
|
||||
import br.com.cybercore.signer.ui.CyberCoreUiConstants;
|
||||
import br.com.cybercore.signer.ui.theme.ThemeManager;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.PasswordField;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.stage.Modality;
|
||||
import javafx.stage.Stage;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
/**
|
||||
* Diálogo modal para inserção segura da senha do certificado A1 (.pfx / .p12).
|
||||
*/
|
||||
public class A1PasswordDialog {
|
||||
|
||||
private final Stage stage;
|
||||
private final CompletableFuture<char[]> passwordFuture = new CompletableFuture<>();
|
||||
|
||||
public A1PasswordDialog(Stage owner, String fileName) {
|
||||
this.stage = new Stage();
|
||||
this.stage.initModality(Modality.APPLICATION_MODAL);
|
||||
if (owner != null) {
|
||||
this.stage.initOwner(owner);
|
||||
}
|
||||
this.stage.setTitle("CyberCore Signer — Senha do Certificado A1");
|
||||
this.stage.setResizable(false);
|
||||
|
||||
initLayout(fileName);
|
||||
}
|
||||
|
||||
private void initLayout(String fileName) {
|
||||
VBox root = new VBox(16);
|
||||
root.getStyleClass().add("dialog-container");
|
||||
root.setPadding(new Insets(24));
|
||||
root.setPrefWidth(440);
|
||||
|
||||
Label header = new Label("IMPORTAR CERTIFICADO A1");
|
||||
header.getStyleClass().add("dialog-header");
|
||||
|
||||
Label desc = new Label("Digite a senha do arquivo para desbloquear o certificado digital.");
|
||||
desc.getStyleClass().add("brand-subtitle");
|
||||
|
||||
VBox fileBox = new VBox(4);
|
||||
fileBox.getStyleClass().add("dialog-box");
|
||||
Label fileTitle = new Label("Arquivo selecionado:");
|
||||
fileTitle.getStyleClass().add("dialog-field-title");
|
||||
Label fileNameLabel = new Label(fileName);
|
||||
fileNameLabel.getStyleClass().add("detail-value");
|
||||
fileBox.getChildren().addAll(fileTitle, fileNameLabel);
|
||||
|
||||
VBox inputGroup = new VBox(8);
|
||||
Label pwdTitle = new Label("Senha do Certificado:");
|
||||
pwdTitle.getStyleClass().add("dialog-field-title");
|
||||
|
||||
PasswordField passwordField = new PasswordField();
|
||||
passwordField.setPromptText("Digite a senha...");
|
||||
|
||||
inputGroup.getChildren().addAll(pwdTitle, passwordField);
|
||||
|
||||
HBox actionsRow = new HBox(12);
|
||||
actionsRow.setAlignment(Pos.CENTER_RIGHT);
|
||||
|
||||
Button btnCancel = new Button("Cancelar");
|
||||
btnCancel.getStyleClass().add("btn-secondary");
|
||||
btnCancel.setOnAction(e -> {
|
||||
passwordFuture.complete(null);
|
||||
stage.close();
|
||||
});
|
||||
|
||||
Button btnConfirm = new Button("Importar");
|
||||
btnConfirm.getStyleClass().add("btn-primary");
|
||||
btnConfirm.setDefaultButton(true);
|
||||
btnConfirm.setOnAction(e -> {
|
||||
String text = passwordField.getText();
|
||||
passwordFuture.complete(text != null ? text.toCharArray() : new char[0]);
|
||||
stage.close();
|
||||
});
|
||||
|
||||
actionsRow.getChildren().addAll(btnCancel, btnConfirm);
|
||||
|
||||
root.getChildren().addAll(header, desc, fileBox, inputGroup, actionsRow);
|
||||
|
||||
Scene scene = new Scene(root);
|
||||
ThemeManager.applyTheme(scene);
|
||||
|
||||
stage.setScene(scene);
|
||||
stage.setOnCloseRequest(e -> passwordFuture.complete(null));
|
||||
}
|
||||
|
||||
public CompletableFuture<char[]> showAndAwait() {
|
||||
stage.show();
|
||||
return passwordFuture;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package br.com.cybercore.signer.ui.view;
|
||||
|
||||
import br.com.cybercore.signer.ui.CyberCoreUiConstants;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.layout.VBox;
|
||||
|
||||
/**
|
||||
* Painel informativo Sobre o CyberCore Signer.
|
||||
*/
|
||||
@SuppressWarnings("this-escape")
|
||||
public final class AboutView extends VBox {
|
||||
|
||||
public AboutView() {
|
||||
initView();
|
||||
}
|
||||
|
||||
private void initView() {
|
||||
setSpacing(16);
|
||||
setAlignment(Pos.CENTER);
|
||||
getStyleClass().add("content-pane");
|
||||
|
||||
Label brandLabel = new Label(CyberCoreUiConstants.APP_NAME);
|
||||
brandLabel.getStyleClass().add("brand-title");
|
||||
brandLabel.setStyle("-fx-font-size: 24px;");
|
||||
|
||||
Label versionLabel = new Label("Versão " + CyberCoreUiConstants.APP_VERSION + " • CCSP/1");
|
||||
versionLabel.getStyleClass().add("status-badge-active");
|
||||
|
||||
Label descLabel = new Label("Agente desktop profissional de assinatura digital segura para aplicações web.");
|
||||
descLabel.getStyleClass().add("section-subtitle");
|
||||
|
||||
VBox detailsBox = new VBox(8);
|
||||
detailsBox.setAlignment(Pos.CENTER);
|
||||
detailsBox.getStyleClass().add("dialog-box");
|
||||
|
||||
Label p1 = new Label("• Criptografia local via JCA/JCE e PKCS#11.");
|
||||
Label p2 = new Label("• A chave privada e o PIN nunca saem deste computador.");
|
||||
Label p3 = new Label("• Comunicação restrita à interface local loopback (127.0.0.1).");
|
||||
Label p4 = new Label("• Proteção contra replay attacks e validação estrita de origem.");
|
||||
|
||||
for (Label lbl : new Label[]{p1, p2, p3, p4}) {
|
||||
lbl.getStyleClass().add("cert-detail");
|
||||
detailsBox.getChildren().add(lbl);
|
||||
}
|
||||
|
||||
Label copyLabel = new Label("© 2026 CyberCore. Todos os direitos reservados.");
|
||||
copyLabel.getStyleClass().add("dialog-subtitle");
|
||||
|
||||
getChildren().addAll(brandLabel, versionLabel, descLabel, detailsBox, copyLabel);
|
||||
}
|
||||
}
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
package br.com.cybercore.signer.ui.view;
|
||||
|
||||
import br.com.cybercore.signer.ui.CyberCoreUiConstants;
|
||||
import br.com.cybercore.signer.ui.theme.ThemeManager;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.stage.Modality;
|
||||
import javafx.stage.Stage;
|
||||
import javafx.stage.StageStyle;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
/**
|
||||
* Diálogo modal para solicitar autorização explícita do usuário quando uma nova aplicação web
|
||||
* tenta se conectar ao CyberCore Signer pela primeira vez.
|
||||
*/
|
||||
public class ApplicationApprovalDialog {
|
||||
|
||||
private final Stage stage;
|
||||
private final CompletableFuture<Boolean> approvalFuture = new CompletableFuture<>();
|
||||
|
||||
public ApplicationApprovalDialog(Stage ownerStage, String origin, String appName, String applicationId) {
|
||||
stage = new Stage();
|
||||
stage.initOwner(ownerStage);
|
||||
stage.initModality(Modality.APPLICATION_MODAL);
|
||||
stage.initStyle(StageStyle.UTILITY);
|
||||
stage.setTitle("CyberCore Signer — Solicitação de Conexão Web");
|
||||
stage.setWidth(520);
|
||||
stage.setResizable(false);
|
||||
|
||||
VBox root = new VBox(18);
|
||||
root.setPadding(new Insets(24));
|
||||
root.getStyleClass().add("dialog-container");
|
||||
|
||||
// Cabeçalho
|
||||
VBox headerBox = new VBox(4);
|
||||
Label titleLabel = new Label("NOVA APLICAÇÃO WEB SOLICITA ACESSO");
|
||||
titleLabel.getStyleClass().add("dialog-header");
|
||||
|
||||
Label subtitleLabel = new Label("Um sistema web está tentando se conectar ao agente local CyberCore Signer.");
|
||||
subtitleLabel.getStyleClass().add("dialog-subtitle");
|
||||
headerBox.getChildren().addAll(titleLabel, subtitleLabel);
|
||||
|
||||
// Painel de Detalhes da Aplicação
|
||||
VBox detailsBox = new VBox(10);
|
||||
detailsBox.getStyleClass().add("dialog-box");
|
||||
|
||||
VBox appRow = new VBox(2);
|
||||
Label appTitle = new Label("APLICAÇÃO SOLICITANTE");
|
||||
appTitle.getStyleClass().add("dialog-field-title");
|
||||
Label appValue = new Label(appName != null && !appName.isBlank() ? appName : "Aplicação Web Externa");
|
||||
appValue.getStyleClass().add("dialog-field-value");
|
||||
appValue.setStyle("-fx-font-weight: bold;");
|
||||
appRow.getChildren().addAll(appTitle, appValue);
|
||||
|
||||
VBox originRow = new VBox(2);
|
||||
Label originTitle = new Label("ORIGEM WEB (URL)");
|
||||
originTitle.getStyleClass().add("dialog-field-title");
|
||||
Label originValue = new Label(origin != null ? origin : "Origem desconhecida");
|
||||
originValue.setStyle("-fx-text-fill: -fx-accent; -fx-font-size: 13px; -fx-font-family: monospace;");
|
||||
originRow.getChildren().addAll(originTitle, originValue);
|
||||
|
||||
VBox idRow = new VBox(2);
|
||||
Label idTitle = new Label("IDENTIFICADOR DA APLICAÇÃO");
|
||||
idTitle.getStyleClass().add("dialog-field-title");
|
||||
Label idValue = new Label(applicationId);
|
||||
idValue.getStyleClass().add("dialog-field-value");
|
||||
idValue.setStyle("-fx-font-size: 12px; -fx-font-family: monospace;");
|
||||
idRow.getChildren().addAll(idTitle, idValue);
|
||||
|
||||
detailsBox.getChildren().addAll(appRow, originRow, idRow);
|
||||
|
||||
// Aviso de Segurança
|
||||
Label secWarning = new Label(
|
||||
"🛡️ Segurança: Aplicações autorizadas podem consultar a lista pública de certificados " +
|
||||
"instalados e solicitar assinaturas. Nenhuma assinatura será realizada sem a sua confirmação explícita na tela."
|
||||
);
|
||||
secWarning.setWrapText(true);
|
||||
secWarning.setStyle("-fx-text-fill: -fx-text-secondary; -fx-font-size: 11px; -fx-line-spacing: 2px;");
|
||||
|
||||
// Botões de Ação
|
||||
HBox actionsRow = new HBox(12);
|
||||
actionsRow.setAlignment(Pos.CENTER_RIGHT);
|
||||
|
||||
Button btnReject = new Button("Recusar Acesso");
|
||||
btnReject.getStyleClass().add("btn-secondary");
|
||||
btnReject.setOnAction(e -> {
|
||||
approvalFuture.complete(false);
|
||||
stage.close();
|
||||
});
|
||||
|
||||
Button btnAuthorize = new Button("Autorizar Conexão");
|
||||
btnAuthorize.getStyleClass().add("btn-primary");
|
||||
btnAuthorize.setDefaultButton(true);
|
||||
btnAuthorize.setOnAction(e -> {
|
||||
approvalFuture.complete(true);
|
||||
stage.close();
|
||||
});
|
||||
|
||||
actionsRow.getChildren().addAll(btnReject, btnAuthorize);
|
||||
|
||||
root.getChildren().addAll(headerBox, detailsBox, secWarning, actionsRow);
|
||||
|
||||
Scene scene = new Scene(root);
|
||||
ThemeManager.applyTheme(scene);
|
||||
|
||||
stage.setScene(scene);
|
||||
stage.setOnCloseRequest(e -> approvalFuture.complete(false));
|
||||
}
|
||||
|
||||
public CompletableFuture<Boolean> showAndAwait() {
|
||||
stage.show();
|
||||
stage.toFront();
|
||||
return approvalFuture;
|
||||
}
|
||||
}
|
||||
+491
@@ -0,0 +1,491 @@
|
||||
package br.com.cybercore.signer.ui.view;
|
||||
|
||||
import br.com.cybercore.signer.core.crypto.SigningService;
|
||||
import br.com.cybercore.signer.core.domain.CertificateStatus;
|
||||
import br.com.cybercore.signer.core.domain.ManagedCertificate;
|
||||
import br.com.cybercore.signer.core.domain.SigningContext;
|
||||
import br.com.cybercore.signer.core.domain.SigningResult;
|
||||
import br.com.cybercore.signer.core.provider.CertificateManager;
|
||||
import br.com.cybercore.signer.ui.CyberCoreUiConstants;
|
||||
import br.com.cybercore.signer.ui.components.SvgIcons;
|
||||
import br.com.cybercore.signer.ui.theme.ThemeManager;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.concurrent.Task;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.scene.control.Alert;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.ComboBox;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.ListCell;
|
||||
import javafx.scene.control.ProgressBar;
|
||||
import javafx.scene.control.TableColumn;
|
||||
import javafx.scene.control.TableView;
|
||||
import javafx.scene.control.TextField;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.stage.DirectoryChooser;
|
||||
import javafx.stage.FileChooser;
|
||||
import javafx.stage.Modality;
|
||||
import javafx.stage.Stage;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Diálogo modal para execução de Assinatura Digital em Lote de múltiplos arquivos (PDF, XML e arquivos diversos).
|
||||
*/
|
||||
@SuppressWarnings("this-escape")
|
||||
public final class BatchSigningDialog {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(BatchSigningDialog.class);
|
||||
|
||||
private final Stage stage;
|
||||
private final CertificateManager certManager;
|
||||
private final SigningService signingService;
|
||||
|
||||
private final ObservableList<BatchItem> fileItems = FXCollections.observableArrayList();
|
||||
private TableView<BatchItem> tableView;
|
||||
private ComboBox<ManagedCertificate> certCombo;
|
||||
private TextField txtOutputDir;
|
||||
private ProgressBar progressBar;
|
||||
private Label lblProgressStatus;
|
||||
private Button btnStart;
|
||||
private Button btnAddFiles;
|
||||
private Button btnClear;
|
||||
private Button btnChooseOutputDir;
|
||||
|
||||
private volatile boolean isRunning = false;
|
||||
|
||||
public static class BatchItem {
|
||||
private final File file;
|
||||
private final SimpleStringProperty fileName = new SimpleStringProperty();
|
||||
private final SimpleStringProperty fileSize = new SimpleStringProperty();
|
||||
private final SimpleStringProperty status = new SimpleStringProperty("Aguardando");
|
||||
|
||||
public BatchItem(File file) {
|
||||
this.file = file;
|
||||
this.fileName.set(file.getName());
|
||||
this.fileSize.set(formatSize(file.length()));
|
||||
}
|
||||
|
||||
public File getFile() {
|
||||
return file;
|
||||
}
|
||||
|
||||
public String getFileName() {
|
||||
return fileName.get();
|
||||
}
|
||||
|
||||
public SimpleStringProperty fileNameProperty() {
|
||||
return fileName;
|
||||
}
|
||||
|
||||
public String getFileSize() {
|
||||
return fileSize.get();
|
||||
}
|
||||
|
||||
public SimpleStringProperty fileSizeProperty() {
|
||||
return fileSize;
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status.get();
|
||||
}
|
||||
|
||||
public void setStatus(String val) {
|
||||
this.status.set(val);
|
||||
}
|
||||
|
||||
public SimpleStringProperty statusProperty() {
|
||||
return status;
|
||||
}
|
||||
|
||||
private static String formatSize(long bytes) {
|
||||
if (bytes < 1024) return bytes + " B";
|
||||
int exp = (int) (Math.log(bytes) / Math.log(1024));
|
||||
char pre = "KMGTPE".charAt(exp - 1);
|
||||
return String.format("%.1f %sB", bytes / Math.pow(1024, exp), pre);
|
||||
}
|
||||
}
|
||||
|
||||
public BatchSigningDialog(Stage owner, CertificateManager certManager, SigningService signingService) {
|
||||
this.certManager = Objects.requireNonNull(certManager, "CertificateManager não pode ser nulo.");
|
||||
this.signingService = Objects.requireNonNull(signingService, "SigningService não pode ser nulo.");
|
||||
|
||||
this.stage = new Stage();
|
||||
this.stage.initModality(Modality.APPLICATION_MODAL);
|
||||
if (owner != null) {
|
||||
this.stage.initOwner(owner);
|
||||
}
|
||||
this.stage.setTitle("CyberCore Signer — Assinatura Digital em Lote");
|
||||
this.stage.setResizable(true);
|
||||
this.stage.setMinWidth(750);
|
||||
this.stage.setMinHeight(580);
|
||||
|
||||
initLayout();
|
||||
}
|
||||
|
||||
private void initLayout() {
|
||||
VBox root = new VBox(16);
|
||||
root.setPadding(new Insets(20));
|
||||
root.getStyleClass().add("dialog-container");
|
||||
|
||||
// Topo / Cabeçalho
|
||||
HBox header = new HBox(12);
|
||||
header.setAlignment(Pos.CENTER_LEFT);
|
||||
header.getChildren().addAll(
|
||||
SvgIcons.batchIcon(),
|
||||
createHeaderLabel("ASSINATURA DIGITAL EM LOTE", "Assine múltiplos arquivos de uma só vez utilizando seu Certificado Digital ICP-Brasil")
|
||||
);
|
||||
|
||||
// Barra de Ação de Arquivos
|
||||
HBox fileActions = new HBox(10);
|
||||
fileActions.setAlignment(Pos.CENTER_LEFT);
|
||||
|
||||
btnAddFiles = new Button("Adicionar Arquivos...");
|
||||
btnAddFiles.getStyleClass().add("btn-primary");
|
||||
btnAddFiles.setOnAction(e -> handleAddFiles());
|
||||
|
||||
btnClear = new Button("Limpar Lista");
|
||||
btnClear.getStyleClass().add("btn-secondary");
|
||||
btnClear.setOnAction(e -> {
|
||||
if (!isRunning) fileItems.clear();
|
||||
});
|
||||
|
||||
fileActions.getChildren().addAll(btnAddFiles, btnClear);
|
||||
|
||||
// Tabela de Arquivos
|
||||
tableView = new TableView<>(fileItems);
|
||||
VBox.setVgrow(tableView, Priority.ALWAYS);
|
||||
|
||||
TableColumn<BatchItem, String> colName = new TableColumn<>("Nome do Arquivo");
|
||||
colName.setCellValueFactory(c -> c.getValue().fileNameProperty());
|
||||
colName.setPrefWidth(320);
|
||||
|
||||
TableColumn<BatchItem, String> colSize = new TableColumn<>("Tamanho");
|
||||
colSize.setCellValueFactory(c -> c.getValue().fileSizeProperty());
|
||||
colSize.setPrefWidth(120);
|
||||
|
||||
TableColumn<BatchItem, String> colStatus = new TableColumn<>("Status");
|
||||
colStatus.setCellValueFactory(c -> c.getValue().statusProperty());
|
||||
tableView.getColumns().add(colName);
|
||||
tableView.getColumns().add(colSize);
|
||||
tableView.getColumns().add(colStatus);
|
||||
tableView.setPlaceholder(new Label("Nenhum arquivo adicionado. Clique em 'Adicionar Arquivos...' para selecionar."));
|
||||
|
||||
// Seção de Configuração do Certificado e Destino
|
||||
VBox configBox = new VBox(10);
|
||||
configBox.getStyleClass().add("dialog-box");
|
||||
|
||||
// Seleção do Certificado
|
||||
HBox certRow = new HBox(10);
|
||||
certRow.setAlignment(Pos.CENTER_LEFT);
|
||||
Label lblCert = new Label("Certificado Digital:");
|
||||
lblCert.getStyleClass().add("dialog-field-title");
|
||||
lblCert.setStyle("-fx-min-width: 140;");
|
||||
|
||||
certCombo = new ComboBox<>();
|
||||
certCombo.setMaxWidth(Double.MAX_VALUE);
|
||||
HBox.setHgrow(certCombo, Priority.ALWAYS);
|
||||
populateCertificates();
|
||||
|
||||
certCombo.setCellFactory(lv -> new ListCell<>() {
|
||||
@Override
|
||||
protected void updateItem(ManagedCertificate item, boolean empty) {
|
||||
super.updateItem(item, empty);
|
||||
if (empty || item == null) {
|
||||
setText(null);
|
||||
} else {
|
||||
String doc = item.cpf() != null ? "CPF: " + item.cpf() : (item.cnpj() != null ? "CNPJ: " + item.cnpj() : "");
|
||||
setText(item.commonName() + " (" + item.type() + ") " + doc);
|
||||
}
|
||||
}
|
||||
});
|
||||
certCombo.setButtonCell(new ListCell<>() {
|
||||
@Override
|
||||
protected void updateItem(ManagedCertificate item, boolean empty) {
|
||||
super.updateItem(item, empty);
|
||||
if (empty || item == null) {
|
||||
setText("Selecione um Certificado Digital...");
|
||||
} else {
|
||||
String doc = item.cpf() != null ? " [CPF: " + item.cpf() + "]" : (item.cnpj() != null ? " [CNPJ: " + item.cnpj() + "]" : "");
|
||||
setText(item.commonName() + doc + " • " + item.type());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
certRow.getChildren().addAll(lblCert, certCombo);
|
||||
|
||||
// Pasta de Saída
|
||||
HBox outputRow = new HBox(10);
|
||||
outputRow.setAlignment(Pos.CENTER_LEFT);
|
||||
Label lblOutput = new Label("Pasta de Destino:");
|
||||
lblOutput.getStyleClass().add("dialog-field-title");
|
||||
lblOutput.setStyle("-fx-min-width: 140;");
|
||||
|
||||
txtOutputDir = new TextField();
|
||||
txtOutputDir.setPromptText("Salvar na mesma pasta dos arquivos originais");
|
||||
txtOutputDir.setMaxWidth(Double.MAX_VALUE);
|
||||
HBox.setHgrow(txtOutputDir, Priority.ALWAYS);
|
||||
|
||||
btnChooseOutputDir = new Button("Selecionar...");
|
||||
btnChooseOutputDir.getStyleClass().add("btn-secondary");
|
||||
btnChooseOutputDir.setOnAction(e -> handleChooseOutputDir());
|
||||
|
||||
outputRow.getChildren().addAll(lblOutput, txtOutputDir, btnChooseOutputDir);
|
||||
|
||||
configBox.getChildren().addAll(certRow, outputRow);
|
||||
|
||||
// Progresso e Ações Finais
|
||||
VBox progressBox = new VBox(6);
|
||||
progressBar = new ProgressBar(0);
|
||||
progressBar.setMaxWidth(Double.MAX_VALUE);
|
||||
progressBar.setVisible(false);
|
||||
|
||||
lblProgressStatus = new Label("");
|
||||
lblProgressStatus.getStyleClass().add("dialog-subtitle");
|
||||
|
||||
progressBox.getChildren().addAll(progressBar, lblProgressStatus);
|
||||
|
||||
HBox footer = new HBox(12);
|
||||
footer.setAlignment(Pos.CENTER_RIGHT);
|
||||
|
||||
Button btnClose = new Button("Fechar");
|
||||
btnClose.getStyleClass().add("btn-secondary");
|
||||
btnClose.setOnAction(e -> {
|
||||
if (!isRunning) stage.close();
|
||||
});
|
||||
|
||||
btnStart = new Button("Iniciar Assinatura em Lote");
|
||||
btnStart.getStyleClass().add("btn-primary");
|
||||
btnStart.setOnAction(e -> startBatchSigning());
|
||||
|
||||
footer.getChildren().addAll(btnClose, btnStart);
|
||||
|
||||
root.getChildren().addAll(header, fileActions, tableView, configBox, progressBox, footer);
|
||||
|
||||
Scene scene = new Scene(root);
|
||||
ThemeManager.applyTheme(scene);
|
||||
|
||||
stage.setScene(scene);
|
||||
}
|
||||
|
||||
private void handleAddFiles() {
|
||||
FileChooser fileChooser = new FileChooser();
|
||||
fileChooser.setTitle("Selecionar Arquivos para Assinatura em Lote");
|
||||
fileChooser.getExtensionFilters().addAll(
|
||||
new FileChooser.ExtensionFilter("Todos os Arquivos Suportados", "*.pdf", "*.xml", "*.txt", "*.doc", "*.docx", "*.*"),
|
||||
new FileChooser.ExtensionFilter("Arquivos PDF (*.pdf)", "*.pdf"),
|
||||
new FileChooser.ExtensionFilter("Arquivos XML (*.xml)", "*.xml")
|
||||
);
|
||||
|
||||
List<File> selected = fileChooser.showOpenMultipleDialog(stage);
|
||||
if (selected != null) {
|
||||
for (File f : selected) {
|
||||
boolean exists = fileItems.stream().anyMatch(item -> item.getFile().getAbsolutePath().equals(f.getAbsolutePath()));
|
||||
if (!exists) {
|
||||
fileItems.add(new BatchItem(f));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void handleChooseOutputDir() {
|
||||
DirectoryChooser dirChooser = new DirectoryChooser();
|
||||
dirChooser.setTitle("Selecionar Pasta para Salvar Arquivos Assinados");
|
||||
File dir = dirChooser.showDialog(stage);
|
||||
if (dir != null) {
|
||||
txtOutputDir.setText(dir.getAbsolutePath());
|
||||
}
|
||||
}
|
||||
|
||||
private void populateCertificates() {
|
||||
try {
|
||||
List<ManagedCertificate> certs = certManager.getAvailableCertificates();
|
||||
certCombo.setItems(FXCollections.observableArrayList(certs));
|
||||
if (!certs.isEmpty()) {
|
||||
certCombo.getSelectionModel().select(0);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.warn("Erro ao listar certificados para lote: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void startBatchSigning() {
|
||||
if (fileItems.isEmpty()) {
|
||||
showAlert("Nenhum Arquivo", "Por favor, adicione pelo menos um arquivo para assinar.");
|
||||
return;
|
||||
}
|
||||
|
||||
ManagedCertificate selectedCert = certCombo.getValue();
|
||||
if (selectedCert == null) {
|
||||
showAlert("Certificado Não Selecionado", "Selecione o Certificado Digital que será utilizado para as assinaturas.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedCert.status() != CertificateStatus.AVAILABLE) {
|
||||
showAlert("Certificado Indisponível", "O certificado selecionado não está pronto ou disponível para assinatura.");
|
||||
return;
|
||||
}
|
||||
|
||||
isRunning = true;
|
||||
setControlsDisabled(true);
|
||||
progressBar.setVisible(true);
|
||||
progressBar.setProgress(0);
|
||||
|
||||
List<BatchItem> itemsToProcess = new ArrayList<>(fileItems);
|
||||
String customOutDir = txtOutputDir.getText().trim();
|
||||
|
||||
Task<Void> batchTask = new Task<>() {
|
||||
@Override
|
||||
protected Void call() throws Exception {
|
||||
int total = itemsToProcess.size();
|
||||
int successCount = 0;
|
||||
|
||||
for (int i = 0; i < total; i++) {
|
||||
BatchItem item = itemsToProcess.get(i);
|
||||
final int currentIdx = i + 1;
|
||||
|
||||
Platform.runLater(() -> {
|
||||
item.setStatus("Assinando...");
|
||||
progressBar.setProgress((double) currentIdx / total);
|
||||
lblProgressStatus.setText(String.format("Processando arquivo %d de %d: %s", currentIdx, total, item.getFileName()));
|
||||
});
|
||||
|
||||
try {
|
||||
// Determina local de gravação
|
||||
Path targetDir = customOutDir.isEmpty()
|
||||
? item.getFile().toPath().getParent()
|
||||
: Path.of(customOutDir);
|
||||
|
||||
String lowerName = item.getFileName().toLowerCase();
|
||||
if (lowerName.endsWith(".pdf")) {
|
||||
// Assinatura PAdES de PDF com selo visual
|
||||
br.com.cybercore.signer.core.pdf.PdfSigningService pdfService =
|
||||
new br.com.cybercore.signer.core.pdf.PdfSigningService(certManager);
|
||||
br.com.cybercore.signer.core.pdf.PdfSignatureOptions options =
|
||||
br.com.cybercore.signer.core.pdf.PdfSignatureOptions.builder(selectedCert.id())
|
||||
.visibleSeal(true)
|
||||
.build();
|
||||
String outPdfName = item.getFileName().substring(0, item.getFileName().length() - 4) + "_assinado.pdf";
|
||||
Path dest = targetDir.resolve(outPdfName);
|
||||
pdfService.signPdfFile(item.getFile(), dest.toFile(), options);
|
||||
} else if (lowerName.endsWith(".xml")) {
|
||||
// Assinatura XMLDSig de documento XML
|
||||
br.com.cybercore.signer.core.xml.XmlSigningService xmlService =
|
||||
new br.com.cybercore.signer.core.xml.XmlSigningService(certManager);
|
||||
br.com.cybercore.signer.core.xml.XmlSignatureOptions options =
|
||||
br.com.cybercore.signer.core.xml.XmlSignatureOptions.wholeDocument(selectedCert.id(), null);
|
||||
String outXmlName = item.getFileName().substring(0, item.getFileName().length() - 4) + "_assinado.xml";
|
||||
Path dest = targetDir.resolve(outXmlName);
|
||||
xmlService.signXmlFile(item.getFile(), dest.toFile(), options);
|
||||
} else {
|
||||
// Assinatura genérica por digest (.p7s)
|
||||
byte[] fileBytes = Files.readAllBytes(item.getFile().toPath());
|
||||
byte[] digest = MessageDigest.getInstance("SHA-256").digest(fileBytes);
|
||||
|
||||
SigningContext context = new SigningContext(
|
||||
UUID.randomUUID().toString(),
|
||||
"cybercore-desktop-batch",
|
||||
digest,
|
||||
"SHA256withRSA",
|
||||
selectedCert.id(),
|
||||
Instant.now()
|
||||
);
|
||||
|
||||
SigningResult result = signingService.sign(context);
|
||||
String outFileName = item.getFileName() + ".p7s";
|
||||
Path dest = targetDir.resolve(outFileName);
|
||||
Files.write(dest, result.signatureBytes());
|
||||
}
|
||||
|
||||
Platform.runLater(() -> item.setStatus("✓ Assinado com Sucesso"));
|
||||
successCount++;
|
||||
} catch (Exception ex) {
|
||||
logger.error("Falha ao assinar arquivo {}: {}", item.getFileName(), ex.getMessage(), ex);
|
||||
Platform.runLater(() -> item.setStatus("✗ Erro: " + ex.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
final int finalSuccess = successCount;
|
||||
Platform.runLater(() -> {
|
||||
lblProgressStatus.setText(String.format("Lote concluído: %d de %d arquivos assinados com sucesso.", finalSuccess, total));
|
||||
showAlert("Assinatura em Lote Concluída",
|
||||
String.format("Processamento finalizado com sucesso!\n\n• Arquivos assinados: %d\n• Total processado: %d", finalSuccess, total));
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void succeeded() {
|
||||
isRunning = false;
|
||||
setControlsDisabled(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void failed() {
|
||||
isRunning = false;
|
||||
setControlsDisabled(false);
|
||||
showAlert("Erro no Lote", "Ocorreu uma falha durante o processamento do lote: " + getException().getMessage());
|
||||
}
|
||||
};
|
||||
|
||||
Thread thread = new Thread(batchTask, "Batch-Signer-Worker");
|
||||
thread.setDaemon(true);
|
||||
thread.start();
|
||||
}
|
||||
|
||||
private void setControlsDisabled(boolean disabled) {
|
||||
btnStart.setDisable(disabled);
|
||||
btnAddFiles.setDisable(disabled);
|
||||
btnClear.setDisable(disabled);
|
||||
certCombo.setDisable(disabled);
|
||||
btnChooseOutputDir.setDisable(disabled);
|
||||
}
|
||||
|
||||
private void showAlert(String title, String message) {
|
||||
Alert alert = new Alert(Alert.AlertType.INFORMATION);
|
||||
alert.setTitle(title);
|
||||
alert.setHeaderText(null);
|
||||
alert.setContentText(message);
|
||||
alert.initOwner(stage);
|
||||
if (alert.getDialogPane() != null && alert.getDialogPane().getScene() != null) {
|
||||
ThemeManager.applyTheme(alert.getDialogPane().getScene());
|
||||
}
|
||||
alert.showAndWait();
|
||||
}
|
||||
|
||||
private VBox createHeaderLabel(String title, String subtitle) {
|
||||
VBox box = new VBox(3);
|
||||
Label t = new Label(title);
|
||||
t.getStyleClass().add("dialog-header");
|
||||
Label s = new Label(subtitle);
|
||||
s.getStyleClass().add("dialog-subtitle");
|
||||
box.getChildren().addAll(t, s);
|
||||
return box;
|
||||
}
|
||||
|
||||
public void show() {
|
||||
stage.show();
|
||||
}
|
||||
|
||||
public Stage getStage() {
|
||||
return stage;
|
||||
}
|
||||
}
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
package br.com.cybercore.signer.ui.view;
|
||||
|
||||
import br.com.cybercore.signer.core.domain.CertificateStatus;
|
||||
import br.com.cybercore.signer.core.domain.CertificateType;
|
||||
import br.com.cybercore.signer.core.domain.ManagedCertificate;
|
||||
import br.com.cybercore.signer.ui.CyberCoreUiConstants;
|
||||
import br.com.cybercore.signer.ui.components.SvgIcons;
|
||||
import br.com.cybercore.signer.ui.theme.ThemeManager;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.ScrollPane;
|
||||
import javafx.scene.layout.GridPane;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.stage.Modality;
|
||||
import javafx.stage.Stage;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Diálogo modal para exibição de detalhes e metadados de um certificado digital.
|
||||
*/
|
||||
public class CertificateDetailsDialog {
|
||||
|
||||
private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm:ss")
|
||||
.withZone(ZoneId.systemDefault());
|
||||
|
||||
private final Stage stage;
|
||||
private final ManagedCertificate cert;
|
||||
|
||||
public CertificateDetailsDialog(Stage owner, ManagedCertificate cert) {
|
||||
this.cert = Objects.requireNonNull(cert, "Certificado não pode ser nulo.");
|
||||
|
||||
this.stage = new Stage();
|
||||
this.stage.initModality(Modality.APPLICATION_MODAL);
|
||||
if (owner != null) {
|
||||
this.stage.initOwner(owner);
|
||||
}
|
||||
this.stage.setTitle("CyberCore Signer — Detalhes do Certificado");
|
||||
this.stage.setResizable(true);
|
||||
this.stage.setMinWidth(600);
|
||||
this.stage.setMinHeight(520);
|
||||
|
||||
initLayout();
|
||||
}
|
||||
|
||||
private void initLayout() {
|
||||
VBox root = new VBox(18);
|
||||
root.getStyleClass().add("dialog-container");
|
||||
root.setPadding(new Insets(24));
|
||||
|
||||
// Cabeçalho
|
||||
HBox headerRow = new HBox(12);
|
||||
headerRow.setAlignment(Pos.CENTER_LEFT);
|
||||
|
||||
Label headerTitle = new Label("DETALHES DO CERTIFICADO");
|
||||
headerTitle.getStyleClass().add("dialog-header");
|
||||
HBox.setHgrow(headerTitle, Priority.ALWAYS);
|
||||
|
||||
Label badge = new Label(cert.type() == CertificateType.A1 ? "A1 • SOFTWARE" : "A3 • HARDWARE/TOKEN");
|
||||
badge.getStyleClass().add(cert.type() == CertificateType.A1 ? "cert-badge-a1" : "cert-badge-a3");
|
||||
|
||||
headerRow.getChildren().addAll(headerTitle, badge);
|
||||
|
||||
// Nome do Titular em destaque
|
||||
VBox titleBox = new VBox(4);
|
||||
Label nameLabel = new Label(cert.commonName() != null ? cert.commonName() : cert.alias());
|
||||
nameLabel.getStyleClass().add("cert-title");
|
||||
nameLabel.setStyle("-fx-font-size: 16px; -fx-font-weight: bold;");
|
||||
|
||||
String docInfo = "";
|
||||
if (cert.cpf() != null) {
|
||||
docInfo = "CPF: " + formatCpf(cert.cpf());
|
||||
} else if (cert.cnpj() != null) {
|
||||
docInfo = "CNPJ: " + formatCnpj(cert.cnpj());
|
||||
}
|
||||
Label docLabel = new Label(docInfo.isEmpty() ? "Alias: " + cert.alias() : docInfo);
|
||||
docLabel.getStyleClass().add("brand-subtitle");
|
||||
|
||||
titleBox.getChildren().addAll(nameLabel, docLabel);
|
||||
|
||||
// Grade de Informações Técnicas
|
||||
GridPane grid = new GridPane();
|
||||
grid.setHgap(16);
|
||||
grid.setVgap(10);
|
||||
grid.setPadding(new Insets(8, 0, 8, 0));
|
||||
|
||||
int row = 0;
|
||||
addField(grid, row++, "Status:", formatStatus(cert));
|
||||
addField(grid, row++, "Dispositivo / Origem:", cert.hardwareDeviceName() != null ? cert.hardwareDeviceName() : cert.providerId());
|
||||
addField(grid, row++, "Emissor (AC):", extractCn(cert.issuerDn()));
|
||||
addField(grid, row++, "Válido de:", cert.notBefore() != null ? DATE_TIME_FORMATTER.format(cert.notBefore()) : "N/D");
|
||||
addField(grid, row++, "Válido até:", formatExpiration(cert.notAfter()));
|
||||
addField(grid, row++, "Algoritmo da Chave:", cert.algorithm() + " (" + cert.keySize() + " bits)");
|
||||
addField(grid, row++, "Número de Série:", cert.serialNumber() != null ? cert.serialNumber() : "N/D");
|
||||
addField(grid, row++, "SHA-1 Thumbprint:", cert.sha1Fingerprint() != null ? formatFingerprint(cert.sha1Fingerprint()) : "N/D");
|
||||
addField(grid, row++, "SHA-256 Thumbprint:", cert.sha256Fingerprint() != null ? formatFingerprint(cert.sha256Fingerprint()) : "N/D");
|
||||
|
||||
VBox detailsBox = new VBox(10);
|
||||
detailsBox.getStyleClass().add("dialog-box");
|
||||
detailsBox.getChildren().add(grid);
|
||||
|
||||
// Subject DN expansível
|
||||
VBox dnBox = new VBox(4);
|
||||
Label dnTitle = new Label("Subject Distinguished Name (DN):");
|
||||
dnTitle.getStyleClass().add("dialog-field-title");
|
||||
Label dnValue = new Label(cert.subjectDn());
|
||||
dnValue.getStyleClass().add("dialog-hash-value");
|
||||
dnValue.setWrapText(true);
|
||||
dnBox.getChildren().addAll(dnTitle, dnValue);
|
||||
detailsBox.getChildren().add(dnBox);
|
||||
|
||||
ScrollPane scroll = new ScrollPane(detailsBox);
|
||||
scroll.setFitToWidth(true);
|
||||
scroll.setStyle("-fx-background: transparent; -fx-background-color: transparent;");
|
||||
VBox.setVgrow(scroll, Priority.ALWAYS);
|
||||
|
||||
// Botões de Ação
|
||||
HBox actionsRow = new HBox(12);
|
||||
actionsRow.setAlignment(Pos.CENTER_RIGHT);
|
||||
|
||||
Button btnClose = new Button(" Fechar", SvgIcons.closeIcon(13));
|
||||
btnClose.getStyleClass().add("btn-secondary");
|
||||
btnClose.setPrefWidth(95);
|
||||
btnClose.setOnAction(e -> stage.close());
|
||||
|
||||
actionsRow.getChildren().add(btnClose);
|
||||
|
||||
root.getChildren().addAll(headerRow, titleBox, scroll, actionsRow);
|
||||
|
||||
Scene scene = new Scene(root);
|
||||
ThemeManager.applyTheme(scene);
|
||||
|
||||
stage.setScene(scene);
|
||||
}
|
||||
|
||||
public void show() {
|
||||
stage.show();
|
||||
}
|
||||
|
||||
private void addField(GridPane grid, int row, String label, String value) {
|
||||
Label lbl = new Label(label);
|
||||
lbl.getStyleClass().add("dialog-field-title");
|
||||
|
||||
Label val = new Label(value);
|
||||
val.getStyleClass().add("detail-value");
|
||||
val.setWrapText(true);
|
||||
|
||||
grid.add(lbl, 0, row);
|
||||
grid.add(val, 1, row);
|
||||
}
|
||||
|
||||
private String formatStatus(ManagedCertificate c) {
|
||||
if (c.status() == CertificateStatus.AVAILABLE && c.isCurrentlyValid()) {
|
||||
return "● Disponível para assinatura";
|
||||
}
|
||||
return "● " + c.status().getDescription();
|
||||
}
|
||||
|
||||
private String formatExpiration(Instant notAfter) {
|
||||
if (notAfter == null) return "N/D";
|
||||
String dateStr = DATE_TIME_FORMATTER.format(notAfter);
|
||||
Instant now = Instant.now();
|
||||
if (now.isAfter(notAfter)) {
|
||||
return dateStr + " (EXPIRADO)";
|
||||
}
|
||||
long daysRemaining = ChronoUnit.DAYS.between(now, notAfter);
|
||||
return dateStr + " (" + daysRemaining + " dias restantes)";
|
||||
}
|
||||
|
||||
private String formatCpf(String cpf) {
|
||||
if (cpf != null && cpf.length() == 11) {
|
||||
return cpf.substring(0, 3) + "." + cpf.substring(3, 6) + "." + cpf.substring(6, 9) + "-" + cpf.substring(9);
|
||||
}
|
||||
return cpf;
|
||||
}
|
||||
|
||||
private String formatCnpj(String cnpj) {
|
||||
if (cnpj != null && cnpj.length() == 14) {
|
||||
return cnpj.substring(0, 2) + "." + cnpj.substring(2, 5) + "." + cnpj.substring(5, 8) + "/" + cnpj.substring(8, 12) + "-" + cnpj.substring(12);
|
||||
}
|
||||
return cnpj;
|
||||
}
|
||||
|
||||
private String extractCn(String dn) {
|
||||
if (dn == null) return "Desconhecido";
|
||||
int idx = dn.indexOf("CN=");
|
||||
if (idx >= 0) {
|
||||
int end = dn.indexOf(',', idx);
|
||||
return end > idx ? dn.substring(idx + 3, end).trim() : dn.substring(idx + 3).trim();
|
||||
}
|
||||
return dn;
|
||||
}
|
||||
|
||||
private String formatFingerprint(String hex) {
|
||||
if (hex == null || hex.length() < 4) return hex;
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < hex.length(); i += 2) {
|
||||
if (i > 0) sb.append(":");
|
||||
sb.append(hex, i, Math.min(i + 2, hex.length()));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
package br.com.cybercore.signer.ui.view;
|
||||
|
||||
import br.com.cybercore.signer.core.domain.ManagedCertificate;
|
||||
import br.com.cybercore.signer.core.provider.CertificateManager;
|
||||
import br.com.cybercore.signer.core.provider.Pkcs12CertificateProvider;
|
||||
import br.com.cybercore.signer.ui.CyberCoreUiConstants;
|
||||
import br.com.cybercore.signer.ui.theme.ThemeManager;
|
||||
import br.com.cybercore.signer.ui.components.CertificateCardNode;
|
||||
import br.com.cybercore.signer.ui.components.SvgIcons;
|
||||
import javafx.application.Platform;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.scene.control.Alert;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.ScrollPane;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.stage.FileChooser;
|
||||
import javafx.stage.Modality;
|
||||
import javafx.stage.Stage;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.file.Files;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Diálogo de gerenciamento de Certificados Digitais e Arquivos Seguros.
|
||||
* Permite listar certificados instalados, inspecionar detalhes técnicos (sem teste de assinatura) e importar A1 (.pfx/.p12).
|
||||
*/
|
||||
@SuppressWarnings("this-escape")
|
||||
public final class CertificatesManagerDialog {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(CertificatesManagerDialog.class);
|
||||
|
||||
private final Stage stage;
|
||||
private final CertificateManager certManager;
|
||||
private final VBox certificatesContainer = new VBox(10);
|
||||
|
||||
public CertificatesManagerDialog(Stage owner, CertificateManager certManager) {
|
||||
this.certManager = Objects.requireNonNull(certManager);
|
||||
this.stage = new Stage();
|
||||
this.stage.initModality(Modality.APPLICATION_MODAL);
|
||||
if (owner != null) {
|
||||
this.stage.initOwner(owner);
|
||||
}
|
||||
this.stage.setTitle("CyberCore Signer — Certificados Digitais & Arquivos Seguros");
|
||||
this.stage.setResizable(true);
|
||||
this.stage.setMinWidth(720);
|
||||
this.stage.setMinHeight(520);
|
||||
|
||||
initLayout();
|
||||
}
|
||||
|
||||
private void initLayout() {
|
||||
VBox root = new VBox(16);
|
||||
root.setPadding(new Insets(20));
|
||||
root.getStyleClass().add("dialog-container");
|
||||
|
||||
HBox header = new HBox(12);
|
||||
header.setAlignment(Pos.CENTER_LEFT);
|
||||
header.getChildren().addAll(
|
||||
SvgIcons.certificatesIcon(),
|
||||
createHeaderLabel("CERTIFICADOS DIGITAIS & ARQUIVOS SEGUROS", "Gerencie os certificados ICP-Brasil instalados e importe arquivos A1 (.pfx / .p12)")
|
||||
);
|
||||
|
||||
// Barra de Ações
|
||||
HBox actions = new HBox(10);
|
||||
actions.setAlignment(Pos.CENTER_LEFT);
|
||||
|
||||
Button btnAddA1 = new Button("Importar A1 (.pfx / .p12)...");
|
||||
btnAddA1.getStyleClass().add("btn-primary");
|
||||
btnAddA1.setOnAction(e -> handleImportA1());
|
||||
|
||||
Button btnRefresh = new Button("Atualizar Lista");
|
||||
btnRefresh.getStyleClass().add("btn-secondary");
|
||||
btnRefresh.setOnAction(e -> refreshList());
|
||||
|
||||
actions.getChildren().addAll(btnAddA1, btnRefresh);
|
||||
|
||||
// Container com Scroll
|
||||
ScrollPane scroll = new ScrollPane(certificatesContainer);
|
||||
scroll.setFitToWidth(true);
|
||||
scroll.setStyle("-fx-background: transparent; -fx-background-color: transparent;");
|
||||
VBox.setVgrow(scroll, Priority.ALWAYS);
|
||||
|
||||
refreshList();
|
||||
|
||||
HBox footer = new HBox();
|
||||
footer.setAlignment(Pos.CENTER_RIGHT);
|
||||
Button btnClose = new Button("Fechar");
|
||||
btnClose.getStyleClass().add("btn-secondary");
|
||||
btnClose.setOnAction(e -> stage.close());
|
||||
footer.getChildren().add(btnClose);
|
||||
|
||||
root.getChildren().addAll(header, actions, scroll, footer);
|
||||
|
||||
Scene scene = new Scene(root);
|
||||
ThemeManager.applyTheme(scene);
|
||||
|
||||
stage.setScene(scene);
|
||||
}
|
||||
|
||||
private void refreshList() {
|
||||
certificatesContainer.getChildren().clear();
|
||||
try {
|
||||
List<ManagedCertificate> certs = certManager.discoverCertificates();
|
||||
if (certs.isEmpty()) {
|
||||
Label emptyLabel = new Label("Nenhum certificado digital identificado.\nConecte seu token USB/Smart Card A3 ou importe um certificado A1 (.pfx/.p12).");
|
||||
emptyLabel.getStyleClass().add("dialog-subtitle");
|
||||
emptyLabel.setStyle("-fx-padding: 20;");
|
||||
certificatesContainer.getChildren().add(emptyLabel);
|
||||
} else {
|
||||
for (ManagedCertificate cert : certs) {
|
||||
CertificateCardNode card = new CertificateCardNode(cert);
|
||||
card.setOnMouseClicked(e -> {
|
||||
// Abre os detalhes SEM botão de teste de assinatura
|
||||
CertificateDetailsDialog details = new CertificateDetailsDialog(stage, cert);
|
||||
details.show();
|
||||
});
|
||||
certificatesContainer.getChildren().add(card);
|
||||
}
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
logger.error("Erro ao listar certificados: {}", ex.getMessage(), ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void handleImportA1() {
|
||||
FileChooser chooser = new FileChooser();
|
||||
chooser.setTitle("Selecionar Certificado A1 (.pfx, .p12)");
|
||||
chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter("Certificados PKCS#12 (*.pfx, *.p12)", "*.pfx", "*.p12"));
|
||||
File file = chooser.showOpenDialog(stage);
|
||||
if (file != null) {
|
||||
A1PasswordDialog pwdDialog = new A1PasswordDialog(stage, file.getName());
|
||||
pwdDialog.showAndAwait().thenAccept(password -> {
|
||||
if (password == null) return;
|
||||
try {
|
||||
byte[] data = Files.readAllBytes(file.toPath());
|
||||
String providerId = "provider-a1-" + Integer.toHexString(file.getAbsolutePath().hashCode());
|
||||
Pkcs12CertificateProvider provider = new Pkcs12CertificateProvider(providerId, file.getName());
|
||||
provider.loadFromBytes(data, password);
|
||||
certManager.registerProvider(provider);
|
||||
Platform.runLater(this::refreshList);
|
||||
|
||||
Platform.runLater(() -> {
|
||||
Alert alert = new Alert(Alert.AlertType.INFORMATION);
|
||||
alert.setTitle("Certificado Importado");
|
||||
alert.setHeaderText(null);
|
||||
alert.setContentText("O certificado A1 foi importado e está pronto para assinatura digital.");
|
||||
alert.initOwner(stage);
|
||||
if (alert.getDialogPane() != null && alert.getDialogPane().getScene() != null) {
|
||||
ThemeManager.applyTheme(alert.getDialogPane().getScene());
|
||||
}
|
||||
alert.showAndWait();
|
||||
});
|
||||
} catch (Exception ex) {
|
||||
Platform.runLater(() -> {
|
||||
Alert alert = new Alert(Alert.AlertType.ERROR);
|
||||
alert.setTitle("Falha na Importação");
|
||||
alert.setHeaderText(null);
|
||||
alert.setContentText("Não foi possível carregar o certificado A1: " + ex.getMessage());
|
||||
alert.initOwner(stage);
|
||||
if (alert.getDialogPane() != null && alert.getDialogPane().getScene() != null) {
|
||||
ThemeManager.applyTheme(alert.getDialogPane().getScene());
|
||||
}
|
||||
alert.showAndWait();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private VBox createHeaderLabel(String title, String subtitle) {
|
||||
VBox box = new VBox(3);
|
||||
Label t = new Label(title);
|
||||
t.getStyleClass().add("dialog-header");
|
||||
Label s = new Label(subtitle);
|
||||
s.getStyleClass().add("dialog-subtitle");
|
||||
box.getChildren().addAll(t, s);
|
||||
return box;
|
||||
}
|
||||
|
||||
public void show() {
|
||||
stage.show();
|
||||
}
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
package br.com.cybercore.signer.ui.view;
|
||||
|
||||
import br.com.cybercore.signer.core.diagnostics.DiagnosticReport;
|
||||
import br.com.cybercore.signer.core.diagnostics.DiagnosticService;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.ScrollPane;
|
||||
import javafx.scene.layout.GridPane;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.VBox;
|
||||
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Painel de Diagnóstico do CyberCore Signer.
|
||||
*/
|
||||
@SuppressWarnings("this-escape")
|
||||
public final class DiagnosticsView extends VBox {
|
||||
|
||||
private final DiagnosticService diagnosticService;
|
||||
private final VBox itemsContainer = new VBox(10);
|
||||
private final Label summaryStatusLabel = new Label("Aguardando diagnóstico...");
|
||||
|
||||
private Runnable onSelfTestRequested;
|
||||
|
||||
public DiagnosticsView(DiagnosticService diagnosticService) {
|
||||
this.diagnosticService = diagnosticService != null ? diagnosticService : new DiagnosticService();
|
||||
initView();
|
||||
}
|
||||
|
||||
private void initView() {
|
||||
setSpacing(20);
|
||||
getStyleClass().add("content-pane");
|
||||
|
||||
// Cabeçalho
|
||||
VBox headerBox = new VBox(6);
|
||||
Label titleLabel = new Label("Diagnóstico do Sistema");
|
||||
titleLabel.getStyleClass().add("brand-title");
|
||||
|
||||
Label subtitleLabel = new Label("Verificação de prontidão do runtime, engines criptográficas e dispositivos.");
|
||||
subtitleLabel.getStyleClass().add("brand-subtitle");
|
||||
|
||||
headerBox.getChildren().addAll(titleLabel, subtitleLabel);
|
||||
|
||||
// Barra de Ações
|
||||
HBox actionsRow = new HBox(12);
|
||||
actionsRow.setAlignment(Pos.CENTER_LEFT);
|
||||
|
||||
Button btnRun = new Button("Executar Teste Completo");
|
||||
btnRun.getStyleClass().add("btn-primary");
|
||||
btnRun.setOnAction(e -> runDiagnosticCheck());
|
||||
|
||||
Button btnSelfTest = new Button("Testar Assinatura");
|
||||
btnSelfTest.getStyleClass().add("btn-secondary");
|
||||
btnSelfTest.setOnAction(e -> {
|
||||
if (onSelfTestRequested != null) {
|
||||
onSelfTestRequested.run();
|
||||
}
|
||||
});
|
||||
|
||||
summaryStatusLabel.getStyleClass().add("section-subtitle");
|
||||
HBox.setHgrow(summaryStatusLabel, Priority.ALWAYS);
|
||||
|
||||
actionsRow.getChildren().addAll(btnRun, btnSelfTest, summaryStatusLabel);
|
||||
|
||||
// Container de Itens
|
||||
ScrollPane scrollPane = new ScrollPane(itemsContainer);
|
||||
scrollPane.setFitToWidth(true);
|
||||
scrollPane.setStyle("-fx-background: transparent; -fx-background-color: transparent;");
|
||||
VBox.setVgrow(scrollPane, Priority.ALWAYS);
|
||||
|
||||
getChildren().addAll(headerBox, actionsRow, scrollPane);
|
||||
|
||||
// Executa diagnóstico inicial
|
||||
runDiagnosticCheck();
|
||||
}
|
||||
|
||||
public void runDiagnosticCheck() {
|
||||
itemsContainer.getChildren().clear();
|
||||
DiagnosticReport report = diagnosticService.runDiagnostic();
|
||||
|
||||
addDiagnosticRow("Java Runtime", report.javaVersion() + " (" + report.javaVendor() + ")", true);
|
||||
addDiagnosticRow("Crypto Engine (JCA/JCE)", report.cryptoEngineOk() ? "✓ Algoritmos operacionais" : "✗ Falha nos algoritmos", report.cryptoEngineOk());
|
||||
addDiagnosticRow("Sistema Operacional", report.osName() + " " + report.osVersion() + " (" + report.osArch() + ")", true);
|
||||
|
||||
String winStoreText = report.windowsStoreSupported() ? "✓ Disponível (SunMSCAPI)" : (report.isWindows() ? "Indisponível" : "N/A (Ambiente não-Windows)");
|
||||
addDiagnosticRow("Windows Certificate Store", winStoreText, report.windowsStoreSupported() || !report.isWindows());
|
||||
|
||||
String pkcs11Text = report.pkcs11ModuleAvailable() ? "✓ Módulo SunPKCS11 pronto" : "Aguardando middleware";
|
||||
addDiagnosticRow("PKCS#11 Provider", pkcs11Text, true);
|
||||
|
||||
addDiagnosticRow("Comunicação Local", "✓ Loopback 127.0.0.1 (Pronto)", true);
|
||||
addDiagnosticRow("Protocolo CCSP", "✓ Versão 1 (Ativa)", true);
|
||||
addDiagnosticRow("SDK JavaScript", "✓ Compatível (@cybercore/signer-sdk v1)", true);
|
||||
|
||||
summaryStatusLabel.setText("Última verificação concluída com sucesso.");
|
||||
}
|
||||
|
||||
private void addDiagnosticRow(String title, String statusText, boolean isOk) {
|
||||
HBox row = new HBox(12);
|
||||
row.setAlignment(Pos.CENTER_LEFT);
|
||||
row.getStyleClass().add("diagnostic-item");
|
||||
|
||||
Label lblTitle = new Label(title);
|
||||
lblTitle.getStyleClass().add("diagnostic-label");
|
||||
HBox.setHgrow(lblTitle, Priority.ALWAYS);
|
||||
|
||||
Label lblStatus = new Label(statusText);
|
||||
lblStatus.getStyleClass().add(isOk ? "diagnostic-value-ok" : "diagnostic-value-warn");
|
||||
|
||||
row.getChildren().addAll(lblTitle, lblStatus);
|
||||
itemsContainer.getChildren().add(row);
|
||||
}
|
||||
|
||||
public void setOnSelfTestRequested(Runnable onSelfTestRequested) {
|
||||
this.onSelfTestRequested = onSelfTestRequested;
|
||||
}
|
||||
}
|
||||
+260
@@ -0,0 +1,260 @@
|
||||
package br.com.cybercore.signer.ui.view;
|
||||
|
||||
import br.com.cybercore.signer.core.crypto.SigningService;
|
||||
import br.com.cybercore.signer.core.domain.CertificateStatus;
|
||||
import br.com.cybercore.signer.core.domain.ManagedCertificate;
|
||||
import br.com.cybercore.signer.core.domain.SigningContext;
|
||||
import br.com.cybercore.signer.core.domain.SigningResult;
|
||||
import br.com.cybercore.signer.core.provider.CertificateManager;
|
||||
import br.com.cybercore.signer.ui.CyberCoreUiConstants;
|
||||
import br.com.cybercore.signer.ui.theme.ThemeManager;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.scene.control.Alert;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.ComboBox;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.ListCell;
|
||||
import javafx.scene.control.TextField;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.stage.FileChooser;
|
||||
import javafx.stage.Modality;
|
||||
import javafx.stage.Stage;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Diálogo unificado para assinatura digital individual de documentos genéricos (Arquivos, Atachada e Co-Assinar).
|
||||
*/
|
||||
@SuppressWarnings("this-escape")
|
||||
public final class FileSignDialog {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(FileSignDialog.class);
|
||||
|
||||
private final Stage stage;
|
||||
private final CertificateManager certManager;
|
||||
private final SigningService signingService;
|
||||
private final String operationTitle;
|
||||
private final String fileExtensionFilter;
|
||||
private final Node icon;
|
||||
|
||||
private TextField txtFilePath;
|
||||
private ComboBox<ManagedCertificate> certCombo;
|
||||
private File selectedFile;
|
||||
|
||||
public FileSignDialog(Stage owner, String operationTitle, String fileExtensionFilter,
|
||||
Node icon, CertificateManager certManager, SigningService signingService) {
|
||||
this.certManager = Objects.requireNonNull(certManager);
|
||||
this.signingService = Objects.requireNonNull(signingService);
|
||||
this.operationTitle = operationTitle;
|
||||
this.fileExtensionFilter = fileExtensionFilter;
|
||||
this.icon = icon;
|
||||
|
||||
this.stage = new Stage();
|
||||
this.stage.initModality(Modality.APPLICATION_MODAL);
|
||||
if (owner != null) {
|
||||
this.stage.initOwner(owner);
|
||||
}
|
||||
this.stage.setTitle("CyberCore Signer — " + operationTitle);
|
||||
this.stage.setResizable(true);
|
||||
this.stage.setMinWidth(620);
|
||||
this.stage.setMinHeight(380);
|
||||
|
||||
initLayout();
|
||||
}
|
||||
|
||||
private void initLayout() {
|
||||
VBox root = new VBox(18);
|
||||
root.setPadding(new Insets(24));
|
||||
root.getStyleClass().add("dialog-container");
|
||||
|
||||
// Topo
|
||||
HBox header = new HBox(14);
|
||||
header.setAlignment(Pos.CENTER_LEFT);
|
||||
VBox titleBox = new VBox(4);
|
||||
Label lblTitle = new Label(operationTitle.toUpperCase());
|
||||
lblTitle.getStyleClass().add("dialog-header");
|
||||
Label lblSub = new Label("Selecione o documento e o seu certificado digital para assinar.");
|
||||
lblSub.getStyleClass().add("dialog-subtitle");
|
||||
titleBox.getChildren().addAll(lblTitle, lblSub);
|
||||
if (icon != null) {
|
||||
header.getChildren().addAll(icon, titleBox);
|
||||
} else {
|
||||
header.getChildren().add(titleBox);
|
||||
}
|
||||
|
||||
// Seleção de Arquivo
|
||||
VBox fileBox = new VBox(8);
|
||||
fileBox.getStyleClass().add("dialog-box");
|
||||
Label lblFile = new Label("Documento a Assinar:");
|
||||
lblFile.getStyleClass().add("dialog-field-title");
|
||||
|
||||
HBox fileRow = new HBox(10);
|
||||
fileRow.setAlignment(Pos.CENTER_LEFT);
|
||||
txtFilePath = new TextField();
|
||||
txtFilePath.setPromptText("Selecione o arquivo...");
|
||||
txtFilePath.setEditable(false);
|
||||
HBox.setHgrow(txtFilePath, Priority.ALWAYS);
|
||||
|
||||
Button btnBrowse = new Button("Procurar...");
|
||||
btnBrowse.getStyleClass().add("btn-secondary");
|
||||
btnBrowse.setOnAction(e -> handleBrowseFile());
|
||||
|
||||
fileRow.getChildren().addAll(txtFilePath, btnBrowse);
|
||||
fileBox.getChildren().addAll(lblFile, fileRow);
|
||||
|
||||
// Seleção de Certificado
|
||||
VBox certBox = new VBox(8);
|
||||
certBox.getStyleClass().add("dialog-box");
|
||||
Label lblCert = new Label("Certificado Digital:");
|
||||
lblCert.getStyleClass().add("dialog-field-title");
|
||||
|
||||
certCombo = new ComboBox<>();
|
||||
certCombo.setMaxWidth(Double.MAX_VALUE);
|
||||
populateCertificates();
|
||||
|
||||
certCombo.setCellFactory(lv -> new ListCell<>() {
|
||||
@Override
|
||||
protected void updateItem(ManagedCertificate item, boolean empty) {
|
||||
super.updateItem(item, empty);
|
||||
if (empty || item == null) {
|
||||
setText(null);
|
||||
} else {
|
||||
String doc = item.cpf() != null ? " [CPF: " + item.cpf() + "]" : (item.cnpj() != null ? " [CNPJ: " + item.cnpj() + "]" : "");
|
||||
setText(item.commonName() + doc + " (" + item.type() + ")");
|
||||
}
|
||||
}
|
||||
});
|
||||
certCombo.setButtonCell(new ListCell<>() {
|
||||
@Override
|
||||
protected void updateItem(ManagedCertificate item, boolean empty) {
|
||||
super.updateItem(item, empty);
|
||||
if (empty || item == null) {
|
||||
setText(null);
|
||||
} else {
|
||||
String doc = item.cpf() != null ? " [CPF: " + item.cpf() + "]" : (item.cnpj() != null ? " [CNPJ: " + item.cnpj() + "]" : "");
|
||||
setText(item.commonName() + doc + " (" + item.type() + ")");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
certBox.getChildren().addAll(lblCert, certCombo);
|
||||
|
||||
// Rodapé
|
||||
HBox footer = new HBox(12);
|
||||
footer.setAlignment(Pos.CENTER_RIGHT);
|
||||
|
||||
Button btnCancel = new Button("Cancelar");
|
||||
btnCancel.getStyleClass().add("btn-secondary");
|
||||
btnCancel.setOnAction(e -> stage.close());
|
||||
|
||||
Button btnSign = new Button("Assinar Documento");
|
||||
btnSign.getStyleClass().add("btn-primary");
|
||||
btnSign.setDefaultButton(true);
|
||||
btnSign.setOnAction(e -> executeSignature());
|
||||
|
||||
footer.getChildren().addAll(btnCancel, btnSign);
|
||||
|
||||
root.getChildren().addAll(header, fileBox, certBox, footer);
|
||||
|
||||
Scene scene = new Scene(root);
|
||||
ThemeManager.applyTheme(scene);
|
||||
|
||||
stage.setScene(scene);
|
||||
}
|
||||
|
||||
private void handleBrowseFile() {
|
||||
FileChooser chooser = new FileChooser();
|
||||
chooser.setTitle("Selecionar " + operationTitle);
|
||||
|
||||
if ("*.pdf".equalsIgnoreCase(fileExtensionFilter)) {
|
||||
chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter("Documentos PDF (*.pdf)", "*.pdf"));
|
||||
} else if ("*.xml".equalsIgnoreCase(fileExtensionFilter)) {
|
||||
chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter("Arquivos XML (*.xml)", "*.xml"));
|
||||
} else {
|
||||
chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter("Todos os Arquivos (*.*)", "*.*"));
|
||||
}
|
||||
|
||||
File file = chooser.showOpenDialog(stage);
|
||||
if (file != null) {
|
||||
selectedFile = file;
|
||||
txtFilePath.setText(file.getAbsolutePath());
|
||||
}
|
||||
}
|
||||
|
||||
private void populateCertificates() {
|
||||
List<ManagedCertificate> certs = certManager.getAvailableCertificates();
|
||||
certCombo.setItems(FXCollections.observableArrayList(certs));
|
||||
if (!certs.isEmpty()) {
|
||||
certCombo.getSelectionModel().select(0);
|
||||
}
|
||||
}
|
||||
|
||||
private void executeSignature() {
|
||||
if (selectedFile == null || !selectedFile.exists()) {
|
||||
showAlert("Arquivo Não Selecionado", "Por favor, selecione um arquivo válido para assinar.");
|
||||
return;
|
||||
}
|
||||
|
||||
ManagedCertificate cert = certCombo.getValue();
|
||||
if (cert == null || cert.status() != CertificateStatus.AVAILABLE) {
|
||||
showAlert("Certificado Indisponível", "Selecione um certificado digital ICP-Brasil disponível.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
byte[] fileBytes = Files.readAllBytes(selectedFile.toPath());
|
||||
byte[] digest = MessageDigest.getInstance("SHA-256").digest(fileBytes);
|
||||
|
||||
SigningContext context = new SigningContext(
|
||||
UUID.randomUUID().toString(),
|
||||
"cybercore-desktop-app",
|
||||
digest,
|
||||
"SHA256withRSA",
|
||||
cert.id(),
|
||||
Instant.now()
|
||||
);
|
||||
|
||||
SigningResult result = signingService.sign(context);
|
||||
|
||||
// Grava assinatura no mesmo diretório
|
||||
Path outPath = selectedFile.toPath().getParent().resolve(selectedFile.getName() + ".p7s");
|
||||
Files.write(outPath, result.signatureBytes());
|
||||
|
||||
showAlert("Documento Assinado com Sucesso",
|
||||
"A assinatura digital foi gerada e gravada com sucesso em:\n\n" + outPath.toAbsolutePath());
|
||||
stage.close();
|
||||
} catch (Exception ex) {
|
||||
logger.error("Erro ao assinar documento {}: {}", selectedFile.getName(), ex.getMessage(), ex);
|
||||
showAlert("Falha na Assinatura", "Não foi possível concluir a assinatura:\n\n" + ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void showAlert(String title, String message) {
|
||||
Alert alert = new Alert(Alert.AlertType.INFORMATION);
|
||||
alert.setTitle(title);
|
||||
alert.setHeaderText(null);
|
||||
alert.setContentText(message);
|
||||
alert.initOwner(stage);
|
||||
ThemeManager.applyTheme(alert.getDialogPane().getScene());
|
||||
alert.showAndWait();
|
||||
}
|
||||
|
||||
public void show() {
|
||||
stage.show();
|
||||
}
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
package br.com.cybercore.signer.ui.view;
|
||||
|
||||
import br.com.cybercore.signer.ui.CyberCoreUiConstants;
|
||||
import br.com.cybercore.signer.ui.components.SvgIcons;
|
||||
import br.com.cybercore.signer.ui.theme.ThemeManager;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.TextArea;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.stage.Modality;
|
||||
import javafx.stage.Stage;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
/**
|
||||
* Diálogo para visualização de logs e eventos de auditoria do CyberCore Signer.
|
||||
*/
|
||||
@SuppressWarnings("this-escape")
|
||||
public final class LogsViewerDialog {
|
||||
|
||||
private final Stage stage;
|
||||
private final TextArea txtLogs = new TextArea();
|
||||
|
||||
public LogsViewerDialog(Stage owner) {
|
||||
this.stage = new Stage();
|
||||
this.stage.initModality(Modality.APPLICATION_MODAL);
|
||||
if (owner != null) {
|
||||
this.stage.initOwner(owner);
|
||||
}
|
||||
this.stage.setTitle("CyberCore Signer — Visualizador de Logs e Auditoria");
|
||||
this.stage.setResizable(true);
|
||||
this.stage.setMinWidth(680);
|
||||
this.stage.setMinHeight(460);
|
||||
|
||||
initLayout();
|
||||
}
|
||||
|
||||
private void initLayout() {
|
||||
VBox root = new VBox(16);
|
||||
root.setPadding(new Insets(20));
|
||||
root.getStyleClass().add("dialog-container");
|
||||
|
||||
HBox header = new HBox(12);
|
||||
header.setAlignment(Pos.CENTER_LEFT);
|
||||
header.getChildren().addAll(
|
||||
SvgIcons.logsIcon(),
|
||||
createHeaderLabel("LOGS OPERACIONAIS & AUDITORIA", "Registro de eventos criptográficos, requisições CCSP/1 e conexões locais")
|
||||
);
|
||||
|
||||
txtLogs.setEditable(false);
|
||||
txtLogs.getStyleClass().add("log-console-area");
|
||||
VBox.setVgrow(txtLogs, Priority.ALWAYS);
|
||||
|
||||
loadInitialLogs();
|
||||
|
||||
HBox footer = new HBox(10);
|
||||
footer.setAlignment(Pos.CENTER_RIGHT);
|
||||
|
||||
Button btnRefresh = new Button("Atualizar");
|
||||
btnRefresh.getStyleClass().add("btn-secondary");
|
||||
btnRefresh.setOnAction(e -> loadInitialLogs());
|
||||
|
||||
Button btnClose = new Button(" Fechar", SvgIcons.closeIcon(13));
|
||||
btnClose.getStyleClass().add("btn-primary");
|
||||
btnClose.setPrefWidth(95);
|
||||
btnClose.setOnAction(e -> stage.close());
|
||||
|
||||
footer.getChildren().addAll(btnRefresh, btnClose);
|
||||
|
||||
root.getChildren().addAll(header, txtLogs, footer);
|
||||
|
||||
Scene scene = new Scene(root);
|
||||
ThemeManager.applyTheme(scene);
|
||||
|
||||
stage.setScene(scene);
|
||||
}
|
||||
|
||||
private void loadInitialLogs() {
|
||||
String now = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
|
||||
String sb = "[" + now + "] [INFO] CyberCore Signer inicializado com sucesso.\n" +
|
||||
"[" + now + "] [INFO] Servidor local CCSP/1 escutando em http://127.0.0.1:21325\n" +
|
||||
"[" + now + "] [INFO] Provedores criptográficos registrados: Windows Certificate Store, PKCS#12, PKCS#11.\n" +
|
||||
"[" + now + "] [INFO] SmartCardWatcher ativo (monitoramento de tokens USB e Smart Cards ICP-Brasil).\n" +
|
||||
"[" + now + "] [INFO] Política anti-replay: nonces protegidos em memória.\n" +
|
||||
"[" + now + "] [INFO] Registro de aplicações web ativo e pronto para autorização segura.\n";
|
||||
txtLogs.setText(sb);
|
||||
}
|
||||
|
||||
private VBox createHeaderLabel(String title, String subtitle) {
|
||||
VBox box = new VBox(3);
|
||||
Label t = new Label(title);
|
||||
t.getStyleClass().add("dialog-header");
|
||||
Label s = new Label(subtitle);
|
||||
s.getStyleClass().add("dialog-subtitle");
|
||||
box.getChildren().addAll(t, s);
|
||||
return box;
|
||||
}
|
||||
|
||||
public void show() {
|
||||
stage.show();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
package br.com.cybercore.signer.ui.view;
|
||||
|
||||
import br.com.cybercore.signer.ui.CyberCoreUiConstants;
|
||||
import br.com.cybercore.signer.ui.components.SvgIcons;
|
||||
import br.com.cybercore.signer.ui.theme.ThemeManager;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.layout.GridPane;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.Region;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.stage.Stage;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Janela principal moderna do CyberCore Signer.
|
||||
* Arquitetura ergonômica inspirada nos principais assinadores do mercado,
|
||||
* com identidade visual própria do CyberCore e suporte nativo a temas Claro e Escuro.
|
||||
*/
|
||||
@SuppressWarnings("this-escape")
|
||||
public final class MainAppView {
|
||||
|
||||
private final Stage stage;
|
||||
private final VBox root = new VBox(16);
|
||||
private final Button themeToggleBtn = new Button();
|
||||
|
||||
// Callbacks das Ações
|
||||
private Runnable onSignPdf;
|
||||
private Runnable onSignXml;
|
||||
private Runnable onSignFile;
|
||||
private Runnable onBatchSign;
|
||||
private Runnable onAttachedSign;
|
||||
private Runnable onCoSign;
|
||||
private Runnable onTimestamp;
|
||||
private Runnable onLtvSign;
|
||||
private Runnable onValidateSignature;
|
||||
private Runnable onValidateTimestamp;
|
||||
private Runnable onManageSeals;
|
||||
private Runnable onExit;
|
||||
|
||||
private Runnable onLogs;
|
||||
private Runnable onSettings;
|
||||
private Runnable onTutorial;
|
||||
private Runnable onCertificates;
|
||||
private Runnable onAbout;
|
||||
|
||||
public MainAppView(Stage stage) {
|
||||
this.stage = stage;
|
||||
initLayout();
|
||||
}
|
||||
|
||||
private void initLayout() {
|
||||
root.getStyleClass().addAll("cyber-root", "serpro-root");
|
||||
root.setAlignment(Pos.TOP_CENTER);
|
||||
|
||||
// 1. Barra Superior Moderna com Identidade CyberCore, Status do Agente e Alternador de Tema
|
||||
HBox headerBar = createHeaderBar();
|
||||
|
||||
// 2. Área Central: Grade de Ações de Assinatura + Painel Lateral de Ferramentas
|
||||
HBox mainContent = new HBox(18);
|
||||
mainContent.setPadding(new Insets(4, 24, 20, 24));
|
||||
mainContent.setAlignment(Pos.CENTER);
|
||||
VBox.setVgrow(mainContent, Priority.ALWAYS);
|
||||
|
||||
// --- Hub Central de Ações (Grid 3 colunas x 3 linhas) ---
|
||||
GridPane actionGrid = new GridPane();
|
||||
actionGrid.setHgap(12);
|
||||
actionGrid.setVgap(12);
|
||||
actionGrid.setAlignment(Pos.CENTER);
|
||||
HBox.setHgrow(actionGrid, Priority.ALWAYS);
|
||||
|
||||
// Linha 0: Operações de Assinatura
|
||||
actionGrid.add(createActionButton(SvgIcons.pdfIcon(), "Assinar\nPDF", () -> trigger(onSignPdf)), 0, 0);
|
||||
actionGrid.add(createActionButton(SvgIcons.xmlIcon(), "Assinar\nXML", () -> trigger(onSignXml)), 1, 0);
|
||||
actionGrid.add(createActionButton(SvgIcons.attachedIcon(), "Assinatura\nAtachada", () -> trigger(onAttachedSign)), 2, 0);
|
||||
|
||||
// Linha 1: Formatos Avançados, Temporais e Identidade Visual
|
||||
actionGrid.add(createActionButton(SvgIcons.coSignIcon(), "Co-Assinar\nDocumento", () -> trigger(onCoSign)), 0, 1);
|
||||
actionGrid.add(createActionButton(SvgIcons.timestampIcon(), "Carimbo\ndo Tempo", () -> trigger(onTimestamp)), 1, 1);
|
||||
actionGrid.add(createActionButton(SvgIcons.sealIcon(), "Gerenciar\nSelos", () -> trigger(onManageSeals)), 2, 1);
|
||||
|
||||
// Linha 2: Auditoria, Validação e Saída
|
||||
actionGrid.add(createActionButton(SvgIcons.validateIcon(), "Validar\nAssinatura", () -> trigger(onValidateSignature)), 0, 2);
|
||||
actionGrid.add(createActionButton(SvgIcons.validateTimestampIcon(), "Validar\nCarimbo", () -> trigger(onValidateTimestamp)), 1, 2);
|
||||
actionGrid.add(createActionButton(SvgIcons.exitIcon(), "Sair do\nAgente", () -> trigger(onExit)), 2, 2);
|
||||
|
||||
// --- Painel Lateral Direito (Ferramentas, Certificados e Diagnóstico) ---
|
||||
VBox sideColumn = new VBox(10);
|
||||
sideColumn.getStyleClass().add("cyber-side-column");
|
||||
sideColumn.setPrefWidth(195);
|
||||
sideColumn.setMinWidth(190);
|
||||
sideColumn.setMaxWidth(210);
|
||||
sideColumn.setAlignment(Pos.TOP_CENTER);
|
||||
|
||||
sideColumn.getChildren().addAll(
|
||||
createSideButton(SvgIcons.logsIcon(), "Logs", () -> trigger(onLogs)),
|
||||
createSideButton(SvgIcons.settingsIcon(), "Configurações", () -> trigger(onSettings)),
|
||||
createSideButton(SvgIcons.tutorialIcon(), "Tutorial", () -> trigger(onTutorial)),
|
||||
createSideButton(SvgIcons.certificatesIcon(), "Certificados", () -> trigger(onCertificates)),
|
||||
createSideButton(SvgIcons.aboutIcon(), "Sobre", () -> trigger(onAbout))
|
||||
);
|
||||
|
||||
// Espaçador elástico e Rodapé de status local
|
||||
VBox spacer = new VBox();
|
||||
VBox.setVgrow(spacer, Priority.ALWAYS);
|
||||
sideColumn.getChildren().add(spacer);
|
||||
|
||||
VBox footerBox = new VBox(4);
|
||||
footerBox.setAlignment(Pos.CENTER);
|
||||
|
||||
HBox brandFooter = new HBox(6);
|
||||
brandFooter.setAlignment(Pos.CENTER);
|
||||
Node smallLogo = SvgIcons.cyberShieldLogo(16);
|
||||
Label lblBrandFoot = new Label("CyberCore");
|
||||
lblBrandFoot.setStyle("-fx-font-weight: bold; -fx-font-size: 11.5px;");
|
||||
brandFooter.getChildren().addAll(smallLogo, lblBrandFoot);
|
||||
|
||||
Label lblVersion = new Label("v" + CyberCoreUiConstants.APP_VERSION);
|
||||
lblVersion.getStyleClass().addAll("cyber-version-label", "serpro-version-label");
|
||||
|
||||
footerBox.getChildren().addAll(brandFooter, lblVersion);
|
||||
sideColumn.getChildren().add(footerBox);
|
||||
|
||||
mainContent.getChildren().addAll(actionGrid, sideColumn);
|
||||
root.getChildren().addAll(headerBar, mainContent);
|
||||
|
||||
Scene scene = new Scene(root, 800, 500);
|
||||
scene.getStylesheets().add(Objects.requireNonNull(
|
||||
getClass().getResource(CyberCoreUiConstants.MAIN_CSS_PATH)
|
||||
).toExternalForm());
|
||||
|
||||
// Configuração e inicialização do Tema
|
||||
ThemeManager.applyTheme(scene);
|
||||
updateThemeToggleButton();
|
||||
|
||||
ThemeManager.addListener(theme -> updateThemeToggleButton());
|
||||
|
||||
stage.setTitle(CyberCoreUiConstants.APP_NAME);
|
||||
stage.setScene(scene);
|
||||
stage.setResizable(false);
|
||||
stage.setMinWidth(780);
|
||||
stage.setMinHeight(480);
|
||||
stage.setMaxWidth(820);
|
||||
stage.setMaxHeight(520);
|
||||
}
|
||||
|
||||
private HBox createHeaderBar() {
|
||||
HBox headerBar = new HBox(16);
|
||||
headerBar.getStyleClass().add("cyber-header-bar");
|
||||
headerBar.setAlignment(Pos.CENTER_LEFT);
|
||||
|
||||
// Logotipo e Título
|
||||
Node brandLogo = SvgIcons.createAppCrystalLogo(38);
|
||||
VBox titleBox = new VBox(1);
|
||||
titleBox.setAlignment(Pos.CENTER_LEFT);
|
||||
|
||||
HBox brandRow = new HBox(6);
|
||||
brandRow.setAlignment(Pos.CENTER_LEFT);
|
||||
|
||||
Label lblAssinador = new Label("ASSINADOR");
|
||||
lblAssinador.getStyleClass().addAll("cyber-brand-title", "serpro-header-title");
|
||||
lblAssinador.setStyle("-fx-font-weight: 300; -fx-opacity: 0.85;");
|
||||
|
||||
Label lblSep = new Label("|");
|
||||
lblSep.setStyle("-fx-font-size: 16px; -fx-opacity: 0.4;");
|
||||
|
||||
Label lblBrand = new Label("CyberCore");
|
||||
lblBrand.getStyleClass().addAll("cyber-brand-title", "serpro-header-brand");
|
||||
lblBrand.setStyle("-fx-font-weight: bold;");
|
||||
|
||||
brandRow.getChildren().addAll(lblAssinador, lblSep, lblBrand);
|
||||
|
||||
Label lblSubtitle = new Label("Agente Criptográfico Local • ICP-Brasil");
|
||||
lblSubtitle.getStyleClass().add("cyber-brand-subtitle");
|
||||
|
||||
titleBox.getChildren().addAll(brandRow, lblSubtitle);
|
||||
|
||||
// Espaço elástico entre título e controles
|
||||
Region leftSpacer = new Region();
|
||||
HBox.setHgrow(leftSpacer, Priority.ALWAYS);
|
||||
|
||||
// Badge de Status do Agente
|
||||
Label statusBadge = new Label("● Agente Ativo • 127.0.0.1:21325");
|
||||
statusBadge.getStyleClass().add("cyber-status-badge");
|
||||
|
||||
// Botão Alternador de Tema (Claro / Escuro)
|
||||
themeToggleBtn.getStyleClass().add("theme-toggle-btn");
|
||||
themeToggleBtn.setOnAction(e -> {
|
||||
ThemeManager.toggleTheme();
|
||||
if (stage.getScene() != null) {
|
||||
ThemeManager.applyTheme(stage.getScene());
|
||||
}
|
||||
});
|
||||
|
||||
headerBar.getChildren().addAll(brandLogo, titleBox, leftSpacer, statusBadge, themeToggleBtn);
|
||||
return headerBar;
|
||||
}
|
||||
|
||||
private void updateThemeToggleButton() {
|
||||
boolean dark = ThemeManager.isDark();
|
||||
HBox content = new HBox(6);
|
||||
content.setAlignment(Pos.CENTER);
|
||||
|
||||
Node icon = dark ? SvgIcons.sunIcon(15) : SvgIcons.moonIcon(15);
|
||||
Label label = new Label(dark ? "Modo Claro" : "Modo Escuro");
|
||||
label.setStyle("-fx-font-size: 11.5px; -fx-font-weight: bold;");
|
||||
|
||||
content.getChildren().addAll(icon, label);
|
||||
themeToggleBtn.setGraphic(content);
|
||||
}
|
||||
|
||||
private Button createActionButton(Node icon, String text, Runnable onAction) {
|
||||
Button btn = new Button();
|
||||
btn.getStyleClass().addAll("cyber-action-card", "serpro-action-btn");
|
||||
btn.setPrefWidth(162);
|
||||
btn.setMinWidth(158);
|
||||
btn.setPrefHeight(68);
|
||||
btn.setMinHeight(68);
|
||||
|
||||
HBox content = new HBox(10);
|
||||
content.setAlignment(Pos.CENTER_LEFT);
|
||||
|
||||
Label lbl = new Label(text);
|
||||
lbl.getStyleClass().addAll("cyber-action-text", "serpro-action-text");
|
||||
lbl.setWrapText(true);
|
||||
HBox.setHgrow(lbl, Priority.ALWAYS);
|
||||
|
||||
if (icon != null) {
|
||||
content.getChildren().addAll(icon, lbl);
|
||||
} else {
|
||||
content.getChildren().add(lbl);
|
||||
}
|
||||
|
||||
btn.setGraphic(content);
|
||||
btn.setOnAction(e -> {
|
||||
if (onAction != null) onAction.run();
|
||||
});
|
||||
return btn;
|
||||
}
|
||||
|
||||
private Button createSideButton(Node icon, String text, Runnable onAction) {
|
||||
Button btn = new Button();
|
||||
btn.getStyleClass().addAll("cyber-side-card", "serpro-side-btn");
|
||||
btn.setPrefHeight(42);
|
||||
btn.setMinHeight(42);
|
||||
btn.setMaxWidth(Double.MAX_VALUE);
|
||||
|
||||
HBox content = new HBox(10);
|
||||
content.setAlignment(Pos.CENTER_LEFT);
|
||||
|
||||
Label lbl = new Label(text);
|
||||
lbl.getStyleClass().addAll("cyber-side-text", "serpro-side-text");
|
||||
lbl.setWrapText(true);
|
||||
HBox.setHgrow(lbl, Priority.ALWAYS);
|
||||
|
||||
if (icon != null) {
|
||||
content.getChildren().addAll(icon, lbl);
|
||||
} else {
|
||||
content.getChildren().add(lbl);
|
||||
}
|
||||
|
||||
btn.setGraphic(content);
|
||||
btn.setOnAction(e -> {
|
||||
if (onAction != null) onAction.run();
|
||||
});
|
||||
return btn;
|
||||
}
|
||||
|
||||
private void trigger(Runnable r) {
|
||||
if (r != null) r.run();
|
||||
}
|
||||
|
||||
public void show() {
|
||||
stage.centerOnScreen();
|
||||
stage.show();
|
||||
stage.toFront();
|
||||
}
|
||||
|
||||
public void hide() {
|
||||
stage.hide();
|
||||
}
|
||||
|
||||
public boolean isShowing() {
|
||||
return stage.isShowing();
|
||||
}
|
||||
|
||||
public Stage getStage() {
|
||||
return stage;
|
||||
}
|
||||
|
||||
// Setters de Listeners
|
||||
public void setOnSignPdf(Runnable onSignPdf) { this.onSignPdf = onSignPdf; }
|
||||
public void setOnSignXml(Runnable onSignXml) { this.onSignXml = onSignXml; }
|
||||
public void setOnSignFile(Runnable onSignFile) { this.onSignFile = onSignFile; }
|
||||
public void setOnBatchSign(Runnable onBatchSign) { this.onBatchSign = onBatchSign; }
|
||||
public void setOnAttachedSign(Runnable onAttachedSign) { this.onAttachedSign = onAttachedSign; }
|
||||
public void setOnCoSign(Runnable onCoSign) { this.onCoSign = onCoSign; }
|
||||
public void setOnTimestamp(Runnable onTimestamp) { this.onTimestamp = onTimestamp; }
|
||||
public void setOnLtvSign(Runnable onLtvSign) { this.onLtvSign = onLtvSign; }
|
||||
public void setOnValidateSignature(Runnable onValidateSignature) { this.onValidateSignature = onValidateSignature; }
|
||||
public void setOnValidateTimestamp(Runnable onValidateTimestamp) { this.onValidateTimestamp = onValidateTimestamp; }
|
||||
public void setOnManageSeals(Runnable onManageSeals) { this.onManageSeals = onManageSeals; }
|
||||
public void setOnExit(Runnable onExit) { this.onExit = onExit; }
|
||||
|
||||
public void setOnLogs(Runnable onLogs) { this.onLogs = onLogs; }
|
||||
public void setOnSettings(Runnable onSettings) { this.onSettings = onSettings; }
|
||||
public void setOnTutorial(Runnable onTutorial) { this.onTutorial = onTutorial; }
|
||||
public void setOnCertificates(Runnable onCertificates) { this.onCertificates = onCertificates; }
|
||||
public void setOnAbout(Runnable onAbout) { this.onAbout = onAbout; }
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
package br.com.cybercore.signer.ui.view;
|
||||
|
||||
import br.com.cybercore.signer.core.domain.ManagedCertificate;
|
||||
import br.com.cybercore.signer.ui.components.CertificateCardNode;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.ScrollPane;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.VBox;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Painel principal (Dashboard) do CyberCore Signer.
|
||||
*/
|
||||
@SuppressWarnings("this-escape")
|
||||
public final class MainDashboardView extends VBox {
|
||||
|
||||
private final VBox certsContainer = new VBox(12);
|
||||
private final Label countLabel = new Label("0 encontrados");
|
||||
private final boolean devMode;
|
||||
|
||||
private Runnable onRefreshRequested;
|
||||
private Runnable onAddA1Requested;
|
||||
private Consumer<ManagedCertificate> onCertificateSelected;
|
||||
|
||||
public MainDashboardView(boolean devMode) {
|
||||
this.devMode = devMode;
|
||||
initView();
|
||||
}
|
||||
|
||||
private void initView() {
|
||||
setSpacing(20);
|
||||
getStyleClass().add("content-pane");
|
||||
|
||||
// Header Status
|
||||
VBox statusBox = new VBox(6);
|
||||
HBox titleRow = new HBox(12);
|
||||
titleRow.setAlignment(Pos.CENTER_LEFT);
|
||||
|
||||
Label titleLabel = new Label("CyberCore Signer");
|
||||
titleLabel.getStyleClass().add("brand-title");
|
||||
|
||||
Label badge = new Label(devMode ? "● MODO DESENVOLVIMENTO" : "● Serviço ativo");
|
||||
badge.getStyleClass().add(devMode ? "status-badge-dev" : "status-badge-active");
|
||||
|
||||
titleRow.getChildren().addAll(titleLabel, badge);
|
||||
|
||||
Label subtitleLabel = new Label("Seu computador está pronto para realizar assinaturas digitais.");
|
||||
subtitleLabel.getStyleClass().add("brand-subtitle");
|
||||
|
||||
statusBox.getChildren().addAll(titleRow, subtitleLabel);
|
||||
|
||||
// Seção de Certificados
|
||||
HBox certsHeader = new HBox(12);
|
||||
certsHeader.setAlignment(Pos.CENTER_LEFT);
|
||||
|
||||
Label certsTitle = new Label("Certificados Disponíveis");
|
||||
certsTitle.getStyleClass().add("section-title");
|
||||
HBox.setHgrow(certsTitle, Priority.ALWAYS);
|
||||
|
||||
countLabel.getStyleClass().add("section-subtitle");
|
||||
|
||||
Button btnRefresh = new Button("Atualizar");
|
||||
btnRefresh.getStyleClass().add("btn-secondary");
|
||||
btnRefresh.setOnAction(e -> {
|
||||
if (onRefreshRequested != null) onRefreshRequested.run();
|
||||
});
|
||||
|
||||
Button btnAddA1 = new Button("+ Adicionar A1");
|
||||
btnAddA1.getStyleClass().add("btn-primary");
|
||||
btnAddA1.setOnAction(e -> {
|
||||
if (onAddA1Requested != null) onAddA1Requested.run();
|
||||
});
|
||||
|
||||
certsHeader.getChildren().addAll(certsTitle, countLabel, btnRefresh, btnAddA1);
|
||||
|
||||
// Scroll com lista de cartões
|
||||
certsContainer.setPadding(new Insets(4, 0, 4, 0));
|
||||
ScrollPane scrollPane = new ScrollPane(certsContainer);
|
||||
scrollPane.setFitToWidth(true);
|
||||
scrollPane.setStyle("-fx-background: transparent; -fx-background-color: transparent;");
|
||||
VBox.setVgrow(scrollPane, Priority.ALWAYS);
|
||||
|
||||
getChildren().addAll(statusBox, certsHeader, scrollPane);
|
||||
}
|
||||
|
||||
public void updateCertificates(List<ManagedCertificate> certificates) {
|
||||
certsContainer.getChildren().clear();
|
||||
|
||||
if (certificates == null || certificates.isEmpty()) {
|
||||
countLabel.setText("0 encontrados");
|
||||
Label emptyLabel = new Label("Nenhum certificado detectado no momento.\nConecte um token USB/smart card ou adicione um certificado A1.");
|
||||
emptyLabel.getStyleClass().add("section-subtitle");
|
||||
emptyLabel.setPadding(new Insets(30, 0, 0, 0));
|
||||
certsContainer.getChildren().add(emptyLabel);
|
||||
return;
|
||||
}
|
||||
|
||||
countLabel.setText(certificates.size() + " encontrado" + (certificates.size() > 1 ? "s" : ""));
|
||||
for (ManagedCertificate cert : certificates) {
|
||||
CertificateCardNode card = new CertificateCardNode(cert);
|
||||
card.setOnMouseClicked(e -> {
|
||||
if (onCertificateSelected != null) {
|
||||
onCertificateSelected.accept(cert);
|
||||
}
|
||||
});
|
||||
certsContainer.getChildren().add(card);
|
||||
}
|
||||
}
|
||||
|
||||
public void setOnRefreshRequested(Runnable onRefreshRequested) {
|
||||
this.onRefreshRequested = onRefreshRequested;
|
||||
}
|
||||
|
||||
public void setOnAddA1Requested(Runnable onAddA1Requested) {
|
||||
this.onAddA1Requested = onAddA1Requested;
|
||||
}
|
||||
|
||||
public void setOnCertificateSelected(Consumer<ManagedCertificate> onCertificateSelected) {
|
||||
this.onCertificateSelected = onCertificateSelected;
|
||||
}
|
||||
}
|
||||
+1125
File diff suppressed because it is too large
Load Diff
+643
@@ -0,0 +1,643 @@
|
||||
package br.com.cybercore.signer.ui.view;
|
||||
|
||||
import br.com.cybercore.signer.core.pdf.seal.CpfDisplayMode;
|
||||
import br.com.cybercore.signer.core.pdf.seal.PdfSealData;
|
||||
import br.com.cybercore.signer.core.pdf.seal.PdfSealRenderer;
|
||||
import br.com.cybercore.signer.core.pdf.seal.PdfSealTheme;
|
||||
import br.com.cybercore.signer.core.pdf.seal.PdfSealThemeRegistry;
|
||||
import br.com.cybercore.signer.ui.components.SvgIcons;
|
||||
import br.com.cybercore.signer.ui.theme.ThemeManager;
|
||||
import javafx.application.Platform;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.scene.control.Alert;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.CheckBox;
|
||||
import javafx.scene.control.ColorPicker;
|
||||
import javafx.scene.control.ComboBox;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.RadioButton;
|
||||
import javafx.scene.control.ScrollPane;
|
||||
import javafx.scene.control.TextField;
|
||||
import javafx.scene.control.ToggleGroup;
|
||||
import javafx.scene.image.Image;
|
||||
import javafx.scene.image.ImageView;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import javafx.scene.layout.ColumnConstraints;
|
||||
import javafx.scene.layout.GridPane;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.Region;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.scene.paint.Color;
|
||||
import javafx.stage.FileChooser;
|
||||
import javafx.stage.Modality;
|
||||
import javafx.stage.Stage;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.util.Base64;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Diálogo Interativo para Criação, Customização, Importação e Instalação de Selos Digitais.
|
||||
* Permite ao usuário definir cores, textos, dimensões, opções de CPF (completo, mascarado, oculto),
|
||||
* data/hora da assinatura e upload de logotipo personalizado, visualizando em tempo real antes de instalar.
|
||||
*/
|
||||
@SuppressWarnings("this-escape")
|
||||
public final class SealDesignerDialog {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(SealDesignerDialog.class);
|
||||
|
||||
private final Stage stage;
|
||||
private final Consumer<PdfSealTheme> onSealInstalled;
|
||||
|
||||
private TextField txtName;
|
||||
private TextField txtCustomHeader;
|
||||
private TextField txtCustomFooter;
|
||||
|
||||
private ColorPicker cpPrimary;
|
||||
private ColorPicker cpSecondary;
|
||||
private ColorPicker cpBackground;
|
||||
private ColorPicker cpBorder;
|
||||
private ColorPicker cpText;
|
||||
|
||||
private ComboBox<String> cbIconStyle;
|
||||
private String customLogoBase64;
|
||||
private Label lblLogoStatus;
|
||||
private Button btnClearLogo;
|
||||
|
||||
private TextField txtCustomFieldLabel;
|
||||
private TextField txtCustomFieldValue;
|
||||
|
||||
private CheckBox chkIncludeCpf;
|
||||
private RadioButton rbCpfMasked;
|
||||
private RadioButton rbCpfFull;
|
||||
private CheckBox chkIncludeDateTime;
|
||||
|
||||
private ImageView previewImageView;
|
||||
|
||||
public SealDesignerDialog(Stage owner, Consumer<PdfSealTheme> onSealInstalled) {
|
||||
this(owner, null, onSealInstalled);
|
||||
}
|
||||
|
||||
public SealDesignerDialog(Stage owner, PdfSealTheme initialTheme, Consumer<PdfSealTheme> onSealInstalled) {
|
||||
this.onSealInstalled = onSealInstalled;
|
||||
|
||||
this.stage = new Stage();
|
||||
this.stage.initModality(Modality.APPLICATION_MODAL);
|
||||
if (owner != null) {
|
||||
this.stage.initOwner(owner);
|
||||
}
|
||||
this.stage.setTitle("CyberCore Signer — Criador e Instalador de Selos Digitais");
|
||||
this.stage.setResizable(true);
|
||||
this.stage.setMinWidth(860);
|
||||
this.stage.setMinHeight(560);
|
||||
|
||||
initLayout();
|
||||
if (initialTheme != null) {
|
||||
loadThemeIntoForm(initialTheme);
|
||||
}
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
private void initLayout() {
|
||||
VBox root = new VBox(14);
|
||||
root.setPadding(new Insets(16));
|
||||
root.getStyleClass().add("dialog-container");
|
||||
|
||||
// Topo / Cabeçalho
|
||||
HBox header = new HBox(12);
|
||||
header.setAlignment(Pos.CENTER_LEFT);
|
||||
VBox titleBox = new VBox(3);
|
||||
Label lblTitle = new Label("CRIADOR E INSTALADOR DE SELOS VISUAIS");
|
||||
lblTitle.getStyleClass().add("dialog-header");
|
||||
Label lblSub = new Label("Crie sua identidade visual personalizada para assinaturas em PDF e instale no sistema.");
|
||||
lblSub.getStyleClass().add("dialog-subtitle");
|
||||
titleBox.getChildren().addAll(lblTitle, lblSub);
|
||||
header.getChildren().addAll(SvgIcons.sealIcon(), titleBox);
|
||||
|
||||
// Painel Principal: Formulário à esquerda, Preview ao vivo à direita
|
||||
HBox mainSplit = new HBox(16);
|
||||
VBox.setVgrow(mainSplit, Priority.ALWAYS);
|
||||
|
||||
// --- Coluna Esquerda: Controles (Formulário) ---
|
||||
VBox formPane = new VBox(12);
|
||||
formPane.setPrefWidth(430);
|
||||
formPane.setMinWidth(400);
|
||||
|
||||
ScrollPane scrollForm = new ScrollPane(formPane);
|
||||
scrollForm.setFitToWidth(true);
|
||||
VBox.setVgrow(scrollForm, Priority.ALWAYS);
|
||||
scrollForm.setStyle("-fx-background: transparent; -fx-background-color: transparent;");
|
||||
HBox.setHgrow(scrollForm, Priority.ALWAYS);
|
||||
|
||||
// 1. Identificação do Selo e Campos Adicionais
|
||||
VBox idBox = new VBox(8);
|
||||
idBox.getStyleClass().add("dialog-box");
|
||||
Label lblIdSection = new Label("IDENTIFICAÇÃO DO SELO E TITULAR");
|
||||
lblIdSection.getStyleClass().add("dialog-field-title");
|
||||
|
||||
Label lblName = new Label("Nome do Selo:");
|
||||
lblName.getStyleClass().add("form-label");
|
||||
txtName = new TextField("Meu Selo Personalizado");
|
||||
txtName.setPromptText("Nome do selo (ex: Escritório Silva)");
|
||||
txtName.textProperty().addListener((obs, oldV, newV) -> updatePreview());
|
||||
|
||||
Label lblCustomHeaderTitle = new Label("Texto de Cabeçalho:");
|
||||
lblCustomHeaderTitle.getStyleClass().add("form-label");
|
||||
txtCustomHeader = new TextField("MEU ESCRITÓRIO • ASSINADO DIGITALMENTE");
|
||||
txtCustomHeader.setPromptText("Cabeçalho (ex: MINHA EMPRESA)");
|
||||
txtCustomHeader.textProperty().addListener((obs, oldV, newV) -> updatePreview());
|
||||
|
||||
Label lblCustomFooterTitle = new Label("Texto de Rodapé:");
|
||||
lblCustomFooterTitle.getStyleClass().add("form-label");
|
||||
txtCustomFooter = new TextField("Validade jurídica plena conforme MP nº 2.200-2/2001");
|
||||
txtCustomFooter.setPromptText("Rodapé institucional");
|
||||
txtCustomFooter.textProperty().addListener((obs, oldV, newV) -> updatePreview());
|
||||
|
||||
// Campos Adicionais Customizáveis (Matrícula, Cargo, OAB, etc.)
|
||||
Label lblCustomFieldSection = new Label("Campo Personalizado Adicional (Opcional - Matrícula, Cargo, OAB):");
|
||||
lblCustomFieldSection.getStyleClass().add("form-label");
|
||||
|
||||
GridPane customFieldGrid = new GridPane();
|
||||
customFieldGrid.setHgap(10);
|
||||
customFieldGrid.setVgap(6);
|
||||
|
||||
txtCustomFieldLabel = new TextField("Matrícula");
|
||||
txtCustomFieldLabel.setPromptText("Rótulo (ex: Matrícula, Cargo, OAB)");
|
||||
txtCustomFieldLabel.setPrefWidth(150);
|
||||
txtCustomFieldLabel.textProperty().addListener((obs, oldV, newV) -> updatePreview());
|
||||
|
||||
txtCustomFieldValue = new TextField("104829");
|
||||
txtCustomFieldValue.setPromptText("Valor (ex: 104829, Auditor Fiscal)");
|
||||
GridPane.setHgrow(txtCustomFieldValue, Priority.ALWAYS);
|
||||
txtCustomFieldValue.textProperty().addListener((obs, oldV, newV) -> updatePreview());
|
||||
|
||||
Label lblCustomFieldLabelText = new Label("Rótulo do Campo:");
|
||||
lblCustomFieldLabelText.getStyleClass().add("form-label");
|
||||
Label lblCustomFieldValueText = new Label("Valor do Campo:");
|
||||
lblCustomFieldValueText.getStyleClass().add("form-label");
|
||||
|
||||
customFieldGrid.add(lblCustomFieldLabelText, 0, 0);
|
||||
customFieldGrid.add(txtCustomFieldLabel, 0, 1);
|
||||
customFieldGrid.add(lblCustomFieldValueText, 1, 0);
|
||||
customFieldGrid.add(txtCustomFieldValue, 1, 1);
|
||||
|
||||
idBox.getChildren().addAll(lblIdSection,
|
||||
lblName, txtName,
|
||||
lblCustomHeaderTitle, txtCustomHeader,
|
||||
lblCustomFooterTitle, txtCustomFooter,
|
||||
lblCustomFieldSection, customFieldGrid);
|
||||
|
||||
// 2. Cores e Emblema / Logotipo Personalizado
|
||||
VBox styleBox = new VBox(12);
|
||||
styleBox.getStyleClass().add("dialog-box");
|
||||
Label lblStyleSection = new Label("CORES E EMBLEMA / LOGOTIPO");
|
||||
lblStyleSection.getStyleClass().add("dialog-field-title");
|
||||
|
||||
GridPane colorGrid = new GridPane();
|
||||
colorGrid.setHgap(12);
|
||||
colorGrid.setVgap(10);
|
||||
|
||||
ColumnConstraints colLabels = new ColumnConstraints();
|
||||
colLabels.setMinWidth(130);
|
||||
colLabels.setPrefWidth(140);
|
||||
|
||||
ColumnConstraints colPickers = new ColumnConstraints();
|
||||
colPickers.setHgrow(Priority.ALWAYS);
|
||||
colorGrid.getColumnConstraints().addAll(colLabels, colPickers);
|
||||
|
||||
cpPrimary = new ColorPicker(Color.web("#0d47a1"));
|
||||
cpPrimary.setMaxWidth(Double.MAX_VALUE);
|
||||
cpPrimary.setOnAction(e -> updatePreview());
|
||||
|
||||
cpSecondary = new ColorPicker(Color.web("#0284c7"));
|
||||
cpSecondary.setMaxWidth(Double.MAX_VALUE);
|
||||
cpSecondary.setOnAction(e -> updatePreview());
|
||||
|
||||
cpBackground = new ColorPicker(Color.web("#ffffff"));
|
||||
cpBackground.setMaxWidth(Double.MAX_VALUE);
|
||||
cpBackground.setOnAction(e -> updatePreview());
|
||||
|
||||
cpBorder = new ColorPicker(Color.web("#cbd5e1"));
|
||||
cpBorder.setMaxWidth(Double.MAX_VALUE);
|
||||
cpBorder.setOnAction(e -> updatePreview());
|
||||
|
||||
cpText = new ColorPicker(Color.web("#0f172a"));
|
||||
cpText.setMaxWidth(Double.MAX_VALUE);
|
||||
cpText.setOnAction(e -> updatePreview());
|
||||
|
||||
Label lblCpPrimary = new Label("Cor Primária:");
|
||||
lblCpPrimary.getStyleClass().add("form-label");
|
||||
Label lblCpSecondary = new Label("Cor Secundária:");
|
||||
lblCpSecondary.getStyleClass().add("form-label");
|
||||
Label lblCpBackground = new Label("Cor de Fundo:");
|
||||
lblCpBackground.getStyleClass().add("form-label");
|
||||
Label lblCpBorder = new Label("Cor da Borda:");
|
||||
lblCpBorder.getStyleClass().add("form-label");
|
||||
Label lblCpText = new Label("Cor do Texto:");
|
||||
lblCpText.getStyleClass().add("form-label");
|
||||
|
||||
colorGrid.add(lblCpPrimary, 0, 0);
|
||||
colorGrid.add(cpPrimary, 1, 0);
|
||||
colorGrid.add(lblCpSecondary, 0, 1);
|
||||
colorGrid.add(cpSecondary, 1, 1);
|
||||
colorGrid.add(lblCpBackground, 0, 2);
|
||||
colorGrid.add(cpBackground, 1, 2);
|
||||
colorGrid.add(lblCpBorder, 0, 3);
|
||||
colorGrid.add(cpBorder, 1, 3);
|
||||
colorGrid.add(lblCpText, 0, 4);
|
||||
colorGrid.add(cpText, 1, 4);
|
||||
|
||||
// Upload de Logotipo ou Emblema Próprio
|
||||
Label lblLogoHeader = new Label("Upload de Emblema / Logotipo Próprio:");
|
||||
lblLogoHeader.getStyleClass().add("form-label");
|
||||
|
||||
Button btnUploadLogo = new Button("📁 Escolher Imagem (PNG, JPG)...");
|
||||
btnUploadLogo.getStyleClass().add("btn-secondary");
|
||||
btnUploadLogo.setMaxWidth(Double.MAX_VALUE);
|
||||
HBox.setHgrow(btnUploadLogo, Priority.ALWAYS);
|
||||
btnUploadLogo.setOnAction(e -> handleUploadLogo());
|
||||
|
||||
btnClearLogo = new Button("✖ Remover");
|
||||
btnClearLogo.getStyleClass().setAll("btn-danger");
|
||||
btnClearLogo.setMinWidth(Region.USE_PREF_SIZE);
|
||||
btnClearLogo.setDisable(true);
|
||||
btnClearLogo.setOnAction(e -> handleClearLogo());
|
||||
|
||||
HBox logoBtnRow = new HBox(8, btnUploadLogo, btnClearLogo);
|
||||
logoBtnRow.setAlignment(Pos.CENTER_LEFT);
|
||||
|
||||
lblLogoStatus = new Label("Nenhum logotipo carregado (usando emblema vetorial)");
|
||||
lblLogoStatus.setStyle("-fx-font-size: 11px; -fx-text-fill: #64748b;");
|
||||
|
||||
Label lblFallbackIcon = new Label("Emblema Vetorial Alternativo (caso sem logo própria):");
|
||||
lblFallbackIcon.getStyleClass().add("form-label");
|
||||
|
||||
cbIconStyle = new ComboBox<>();
|
||||
cbIconStyle.getItems().addAll("Escudo de Segurança (ICP-Brasil)", "Cristal Tecnológico (CyberCore)", "Carimbo Notarial (Cartório)", "Brasão Estelar (Governamental)");
|
||||
cbIconStyle.setValue("Escudo de Segurança (ICP-Brasil)");
|
||||
cbIconStyle.setMaxWidth(Double.MAX_VALUE);
|
||||
cbIconStyle.setOnAction(e -> updatePreview());
|
||||
|
||||
styleBox.getChildren().addAll(lblStyleSection, colorGrid, lblLogoHeader, logoBtnRow, lblLogoStatus, lblFallbackIcon, cbIconStyle);
|
||||
|
||||
// 3. Regras do Usuário: CPF e Data/Hora
|
||||
VBox rulesBox = new VBox(10);
|
||||
rulesBox.getStyleClass().add("dialog-box");
|
||||
Label lblRulesSection = new Label("PRIVACIDADE DO CPF E DATA/HORA");
|
||||
lblRulesSection.getStyleClass().add("dialog-field-title");
|
||||
|
||||
chkIncludeCpf = new CheckBox("Incluir CPF no Selo");
|
||||
chkIncludeCpf.getStyleClass().add("form-label");
|
||||
chkIncludeCpf.setSelected(true);
|
||||
chkIncludeCpf.setOnAction(e -> {
|
||||
boolean sel = chkIncludeCpf.isSelected();
|
||||
rbCpfMasked.setDisable(!sel);
|
||||
rbCpfFull.setDisable(!sel);
|
||||
updatePreview();
|
||||
});
|
||||
|
||||
ToggleGroup cpfGroup = new ToggleGroup();
|
||||
rbCpfMasked = new RadioButton("CPF Mascarado (***.456.789-**)");
|
||||
rbCpfMasked.getStyleClass().add("form-label");
|
||||
rbCpfMasked.setToggleGroup(cpfGroup);
|
||||
rbCpfMasked.setSelected(true);
|
||||
rbCpfMasked.setOnAction(e -> updatePreview());
|
||||
|
||||
rbCpfFull = new RadioButton("CPF Completo (123.456.789-00)");
|
||||
rbCpfFull.getStyleClass().add("form-label");
|
||||
rbCpfFull.setToggleGroup(cpfGroup);
|
||||
rbCpfFull.setOnAction(e -> updatePreview());
|
||||
|
||||
VBox cpfSubOptions = new VBox(6);
|
||||
cpfSubOptions.setPadding(new Insets(2, 0, 4, 18));
|
||||
cpfSubOptions.getChildren().addAll(rbCpfMasked, rbCpfFull);
|
||||
|
||||
chkIncludeDateTime = new CheckBox("Incluir Data e Hora da Assinatura");
|
||||
chkIncludeDateTime.getStyleClass().add("form-label");
|
||||
chkIncludeDateTime.setSelected(true);
|
||||
chkIncludeDateTime.setOnAction(e -> updatePreview());
|
||||
|
||||
rulesBox.getChildren().addAll(lblRulesSection, chkIncludeCpf, cpfSubOptions, chkIncludeDateTime);
|
||||
|
||||
formPane.getChildren().addAll(idBox, styleBox, rulesBox);
|
||||
|
||||
// --- Coluna Direita: Live Preview e Ações ---
|
||||
VBox previewPane = new VBox(12);
|
||||
previewPane.setAlignment(Pos.TOP_CENTER);
|
||||
previewPane.setPrefWidth(380);
|
||||
previewPane.setMinWidth(360);
|
||||
previewPane.getStyleClass().add("dialog-box");
|
||||
|
||||
Label lblPreviewTitle = new Label("PRÉ-VISUALIZAÇÃO EM TEMPO REAL");
|
||||
lblPreviewTitle.getStyleClass().add("dialog-field-title");
|
||||
|
||||
Label lblPreviewNotice = new Label("O selo sempre exibe o nome completo do signatário com destaque e máxima legibilidade.");
|
||||
lblPreviewNotice.setStyle("-fx-font-size: 11px; -fx-text-fill: #94a3b8;");
|
||||
lblPreviewNotice.setWrapText(true);
|
||||
|
||||
previewImageView = new ImageView();
|
||||
previewImageView.setPreserveRatio(true);
|
||||
previewImageView.setFitWidth(360);
|
||||
|
||||
HBox previewContainer = new HBox(previewImageView);
|
||||
previewContainer.setAlignment(Pos.CENTER);
|
||||
previewContainer.setPadding(new Insets(16, 8, 16, 8));
|
||||
previewContainer.setStyle("-fx-background-color: #090d16; -fx-background-radius: 8px; -fx-border-color: #334155; -fx-border-radius: 8px;");
|
||||
|
||||
VBox previewContentBox = new VBox(10);
|
||||
previewContentBox.setAlignment(Pos.CENTER);
|
||||
previewContentBox.getChildren().addAll(lblPreviewTitle, lblPreviewNotice, previewContainer);
|
||||
VBox.setVgrow(previewContentBox, Priority.ALWAYS);
|
||||
|
||||
// Botões de Importar / Exportar
|
||||
HBox importExportRow = new HBox(10);
|
||||
importExportRow.setAlignment(Pos.CENTER);
|
||||
|
||||
Button btnImport = new Button("📥 Importar Selo...");
|
||||
btnImport.getStyleClass().add("btn-secondary");
|
||||
btnImport.setPrefHeight(32);
|
||||
btnImport.setOnAction(e -> handleImportSeal());
|
||||
|
||||
Button btnExport = new Button("📤 Exportar Selo...");
|
||||
btnExport.getStyleClass().add("btn-secondary");
|
||||
btnExport.setPrefHeight(32);
|
||||
btnExport.setOnAction(e -> handleExportSeal());
|
||||
|
||||
importExportRow.getChildren().addAll(btnImport, btnExport);
|
||||
|
||||
previewPane.getChildren().addAll(previewContentBox, importExportRow);
|
||||
|
||||
mainSplit.getChildren().addAll(scrollForm, previewPane);
|
||||
|
||||
// Rodapé de Ações Finais (Fixado na base da janela, sempre visível e destacado)
|
||||
HBox footer = new HBox(12);
|
||||
footer.setAlignment(Pos.CENTER_RIGHT);
|
||||
footer.setPadding(new Insets(6, 0, 0, 0));
|
||||
|
||||
Button btnCancel = new Button("Cancelar");
|
||||
btnCancel.getStyleClass().add("btn-secondary");
|
||||
btnCancel.setPrefHeight(34);
|
||||
btnCancel.setOnAction(e -> stage.close());
|
||||
|
||||
Button btnInstall = new Button("💾 Instalar Selo no Sistema");
|
||||
btnInstall.getStyleClass().add("btn-primary");
|
||||
btnInstall.setPrefHeight(34);
|
||||
btnInstall.setDefaultButton(true);
|
||||
btnInstall.setOnAction(e -> handleInstallSeal());
|
||||
|
||||
footer.getChildren().addAll(btnCancel, btnInstall);
|
||||
|
||||
root.getChildren().addAll(header, mainSplit, footer);
|
||||
|
||||
Scene scene = new Scene(root, 880, 580);
|
||||
ThemeManager.applyTheme(scene);
|
||||
stage.setScene(scene);
|
||||
}
|
||||
|
||||
private void updatePreview() {
|
||||
Platform.runLater(() -> {
|
||||
try {
|
||||
PdfSealTheme theme = buildThemeFromForm("preview-temp");
|
||||
CpfDisplayMode cpfMode = resolveCpfMode();
|
||||
|
||||
PdfSealData sampleData = new PdfSealData(
|
||||
"Dr. Carlos Alberto de Oliveira Santos Júnior",
|
||||
"CPF: 12345678900",
|
||||
null,
|
||||
"AC CyberCore JUS v5",
|
||||
"8A7F4B2C91D0E3F5",
|
||||
"Assinatura de Documento Digital",
|
||||
"São Paulo, SP",
|
||||
cpfMode,
|
||||
chkIncludeDateTime.isSelected()
|
||||
);
|
||||
|
||||
byte[] pngBytes = PdfSealRenderer.renderToPngBytes(theme, sampleData);
|
||||
previewImageView.setImage(new Image(new ByteArrayInputStream(pngBytes)));
|
||||
} catch (Exception ex) {
|
||||
logger.warn("Erro ao gerar preview de selo: {}", ex.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void handleUploadLogo() {
|
||||
FileChooser chooser = new FileChooser();
|
||||
chooser.setTitle("Selecionar Emblema / Logotipo do Selo");
|
||||
chooser.getExtensionFilters().addAll(
|
||||
new FileChooser.ExtensionFilter("Imagens (*.png, *.jpg, *.jpeg, *.bmp)", "*.png", "*.jpg", "*.jpeg", "*.bmp"),
|
||||
new FileChooser.ExtensionFilter("Todos os Arquivos", "*.*")
|
||||
);
|
||||
File file = chooser.showOpenDialog(stage);
|
||||
if (file != null) {
|
||||
try {
|
||||
byte[] bytes = Files.readAllBytes(file.toPath());
|
||||
customLogoBase64 = Base64.getEncoder().encodeToString(bytes);
|
||||
lblLogoStatus.setText("✓ Imagem carregada: " + file.getName());
|
||||
lblLogoStatus.setStyle("-fx-font-size: 11px; -fx-text-fill: #16a34a; -fx-font-weight: bold;");
|
||||
btnClearLogo.setDisable(false);
|
||||
updatePreview();
|
||||
} catch (Exception ex) {
|
||||
showAlert(Alert.AlertType.ERROR, "Erro ao Carregar Imagem", "Não foi possível carregar o arquivo de imagem: " + ex.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void handleClearLogo() {
|
||||
customLogoBase64 = null;
|
||||
lblLogoStatus.setText("Nenhum logotipo carregado (usando emblema vetorial)");
|
||||
lblLogoStatus.setStyle("-fx-font-size: 11px; -fx-text-fill: #64748b;");
|
||||
btnClearLogo.setDisable(true);
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
private PdfSealTheme buildThemeFromForm(String themeId) {
|
||||
String name = txtName.getText().trim();
|
||||
if (name.isBlank()) name = "Meu Selo Personalizado";
|
||||
|
||||
String icon = switch (cbIconStyle.getValue()) {
|
||||
case "Cristal Tecnológico (CyberCore)" -> "crystal";
|
||||
case "Carimbo Notarial (Cartório)" -> "notary";
|
||||
case "Brasão Estelar (Governamental)" -> "star";
|
||||
default -> "shield";
|
||||
};
|
||||
|
||||
return new PdfSealTheme(
|
||||
themeId,
|
||||
name,
|
||||
"Selo personalizado instalado pelo usuário",
|
||||
320,
|
||||
82,
|
||||
toHex(cpPrimary.getValue()),
|
||||
toHex(cpSecondary.getValue()),
|
||||
toHex(cpBackground.getValue()),
|
||||
toHex(cpBorder.getValue()),
|
||||
toHex(cpText.getValue()),
|
||||
null,
|
||||
txtCustomHeader.getText().trim(),
|
||||
txtCustomFooter.getText().trim(),
|
||||
icon,
|
||||
resolveCpfMode(),
|
||||
chkIncludeDateTime.isSelected(),
|
||||
true,
|
||||
customLogoBase64,
|
||||
txtCustomFieldLabel.getText().trim(),
|
||||
txtCustomFieldValue.getText().trim()
|
||||
);
|
||||
}
|
||||
|
||||
private void loadThemeIntoForm(PdfSealTheme theme) {
|
||||
if (theme == null) return;
|
||||
if (theme.name() != null) txtName.setText(theme.name());
|
||||
if (theme.customHeader() != null) txtCustomHeader.setText(theme.customHeader());
|
||||
if (theme.customFooter() != null) txtCustomFooter.setText(theme.customFooter());
|
||||
if (theme.customFieldLabel() != null) txtCustomFieldLabel.setText(theme.customFieldLabel());
|
||||
if (theme.customFieldValue() != null) txtCustomFieldValue.setText(theme.customFieldValue());
|
||||
|
||||
if (theme.primaryColor() != null) cpPrimary.setValue(Color.web(theme.primaryColor()));
|
||||
if (theme.secondaryColor() != null) cpSecondary.setValue(Color.web(theme.secondaryColor()));
|
||||
if (theme.backgroundColor() != null) cpBackground.setValue(Color.web(theme.backgroundColor()));
|
||||
if (theme.borderColor() != null) cpBorder.setValue(Color.web(theme.borderColor()));
|
||||
if (theme.textColor() != null) cpText.setValue(Color.web(theme.textColor()));
|
||||
|
||||
if (theme.defaultCpfMode() != null) {
|
||||
chkIncludeCpf.setSelected(theme.defaultCpfMode() != CpfDisplayMode.NONE);
|
||||
if (theme.defaultCpfMode() == CpfDisplayMode.MASKED) {
|
||||
rbCpfMasked.setSelected(true);
|
||||
} else if (theme.defaultCpfMode() == CpfDisplayMode.FULL) {
|
||||
rbCpfFull.setSelected(true);
|
||||
}
|
||||
rbCpfMasked.setDisable(!chkIncludeCpf.isSelected());
|
||||
rbCpfFull.setDisable(!chkIncludeCpf.isSelected());
|
||||
}
|
||||
|
||||
chkIncludeDateTime.setSelected(theme.defaultIncludeDateTime());
|
||||
|
||||
if (theme.customLogoBase64() != null && !theme.customLogoBase64().isBlank()) {
|
||||
this.customLogoBase64 = theme.customLogoBase64();
|
||||
lblLogoStatus.setText("✓ Logotipo carregado com sucesso!");
|
||||
lblLogoStatus.setStyle("-fx-font-size: 11px; -fx-text-fill: #16a34a; -fx-font-weight: bold;");
|
||||
btnClearLogo.setDisable(false);
|
||||
} else {
|
||||
this.customLogoBase64 = null;
|
||||
lblLogoStatus.setText("Nenhum logotipo carregado (usando emblema vetorial)");
|
||||
lblLogoStatus.setStyle("-fx-font-size: 11px; -fx-text-fill: #64748b;");
|
||||
btnClearLogo.setDisable(true);
|
||||
}
|
||||
|
||||
if (theme.iconStyle() != null) {
|
||||
switch (theme.iconStyle().toLowerCase()) {
|
||||
case "crystal" -> cbIconStyle.setValue("Cristal Tecnológico (CyberCore)");
|
||||
case "notary" -> cbIconStyle.setValue("Carimbo Notarial (Cartório)");
|
||||
case "star" -> cbIconStyle.setValue("Brasão Estelar (Governamental)");
|
||||
default -> cbIconStyle.setValue("Escudo de Segurança (ICP-Brasil)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private CpfDisplayMode resolveCpfMode() {
|
||||
if (!chkIncludeCpf.isSelected()) {
|
||||
return CpfDisplayMode.NONE;
|
||||
}
|
||||
return rbCpfMasked.isSelected() ? CpfDisplayMode.MASKED : CpfDisplayMode.FULL;
|
||||
}
|
||||
|
||||
private void handleInstallSeal() {
|
||||
String name = txtName.getText().trim();
|
||||
if (name.isBlank()) {
|
||||
showAlert(Alert.AlertType.WARNING, "Nome Obrigatório", "Por favor, defina um nome para o seu selo.");
|
||||
return;
|
||||
}
|
||||
|
||||
String sealId = "seal-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
PdfSealTheme theme = buildThemeFromForm(sealId);
|
||||
|
||||
try {
|
||||
PdfSealThemeRegistry.installCustomTheme(theme);
|
||||
showAlert(Alert.AlertType.INFORMATION, "Selo Instalado com Sucesso!",
|
||||
"O selo '" + theme.name() + "' foi instalado com sucesso e já está disponível para uso imediato em todas as assinaturas.");
|
||||
|
||||
if (onSealInstalled != null) {
|
||||
onSealInstalled.accept(theme);
|
||||
}
|
||||
stage.close();
|
||||
} catch (IOException e) {
|
||||
showAlert(Alert.AlertType.ERROR, "Erro ao Instalar Selo", "Falha ao gravar arquivo de selo no disco: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void handleExportSeal() {
|
||||
FileChooser chooser = new FileChooser();
|
||||
chooser.setTitle("Exportar Selo Digital (.json)");
|
||||
chooser.setInitialFileName("meu-selo.json");
|
||||
chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter("Arquivos JSON de Selo (*.json)", "*.json"));
|
||||
|
||||
File file = chooser.showSaveDialog(stage);
|
||||
if (file != null) {
|
||||
try {
|
||||
PdfSealTheme theme = buildThemeFromForm("exported-seal");
|
||||
PdfSealThemeRegistry.getStorage().exportSeal(theme, file.toPath());
|
||||
showAlert(Alert.AlertType.INFORMATION, "Selo Exportado", "O selo foi exportado com sucesso para:\n" + file.getAbsolutePath());
|
||||
} catch (Exception ex) {
|
||||
showAlert(Alert.AlertType.ERROR, "Erro na Exportação", "Falha ao exportar selo: " + ex.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void handleImportSeal() {
|
||||
FileChooser chooser = new FileChooser();
|
||||
chooser.setTitle("Selecionar Arquivo de Selo (.json)");
|
||||
chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter("Arquivos JSON de Selo (*.json)", "*.json"));
|
||||
|
||||
File file = chooser.showOpenDialog(stage);
|
||||
if (file != null) {
|
||||
try {
|
||||
PdfSealTheme imported = PdfSealThemeRegistry.getStorage().importSeal(file.toPath());
|
||||
PdfSealThemeRegistry.registerTheme(imported);
|
||||
|
||||
loadThemeIntoForm(imported);
|
||||
updatePreview();
|
||||
|
||||
showAlert(Alert.AlertType.INFORMATION, "Selo Importado e Instalado",
|
||||
"O selo '" + imported.name() + "' foi importado com sucesso e está pronto para uso!");
|
||||
|
||||
if (onSealInstalled != null) {
|
||||
onSealInstalled.accept(imported);
|
||||
}
|
||||
stage.close();
|
||||
} catch (Exception ex) {
|
||||
showAlert(Alert.AlertType.ERROR, "Erro na Importação", "Falha ao importar selo do arquivo: " + ex.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void showAlert(Alert.AlertType type, String title, String msg) {
|
||||
Alert a = new Alert(type);
|
||||
a.setTitle(title);
|
||||
a.setHeaderText(null);
|
||||
a.setContentText(msg);
|
||||
a.initOwner(stage);
|
||||
ThemeManager.applyTheme(a.getDialogPane().getScene());
|
||||
a.showAndWait();
|
||||
}
|
||||
|
||||
private static String toHex(Color c) {
|
||||
return String.format("#%02X%02X%02X",
|
||||
(int) (c.getRed() * 255),
|
||||
(int) (c.getGreen() * 255),
|
||||
(int) (c.getBlue() * 255));
|
||||
}
|
||||
|
||||
public void show() {
|
||||
stage.centerOnScreen();
|
||||
stage.show();
|
||||
}
|
||||
}
|
||||
+498
@@ -0,0 +1,498 @@
|
||||
package br.com.cybercore.signer.ui.view;
|
||||
|
||||
import br.com.cybercore.signer.core.config.AgentConfiguration;
|
||||
import br.com.cybercore.signer.core.config.AgentConfigurationService;
|
||||
import br.com.cybercore.signer.core.pdf.seal.CpfDisplayMode;
|
||||
import br.com.cybercore.signer.core.pdf.seal.PdfSealData;
|
||||
import br.com.cybercore.signer.core.pdf.seal.PdfSealRenderer;
|
||||
import br.com.cybercore.signer.core.pdf.seal.PdfSealTheme;
|
||||
import br.com.cybercore.signer.core.pdf.seal.PdfSealThemeRegistry;
|
||||
import br.com.cybercore.signer.ui.components.SvgIcons;
|
||||
import br.com.cybercore.signer.ui.theme.ThemeManager;
|
||||
import javafx.application.Platform;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.scene.control.Alert;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.ButtonType;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.ListCell;
|
||||
import javafx.scene.control.ListView;
|
||||
import javafx.scene.image.Image;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.stage.FileChooser;
|
||||
import javafx.stage.Modality;
|
||||
import javafx.stage.Stage;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Diálogo Central de Gerenciamento e Seleção de Selos Digitais.
|
||||
* Exibe a lista completa de selos disponíveis (embutidos e personalizados),
|
||||
* permite selecionar o selo padrão ativo de assinatura, visualizar em tempo real,
|
||||
* criar novos selos, editar/clonar, excluir, importar e exportar.
|
||||
*/
|
||||
@SuppressWarnings("this-escape")
|
||||
public final class SealManagementDialog {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(SealManagementDialog.class);
|
||||
|
||||
private final Stage stage;
|
||||
private final AgentConfigurationService configService;
|
||||
|
||||
private ListView<PdfSealTheme> listViewSeals;
|
||||
private ImageView previewImageView;
|
||||
private Label lblSelectedName;
|
||||
private Label lblSelectedDesc;
|
||||
private Label lblActiveBadge;
|
||||
private Button btnSetDefault;
|
||||
private Button btnEditClone;
|
||||
private Button btnDelete;
|
||||
private Button btnExport;
|
||||
private Label lblFeedback;
|
||||
|
||||
private String currentDefaultThemeId;
|
||||
|
||||
public SealManagementDialog(Stage owner) {
|
||||
this.configService = new AgentConfigurationService();
|
||||
AgentConfiguration currentConfig = configService.loadConfiguration();
|
||||
this.currentDefaultThemeId = currentConfig.defaultSealThemeId();
|
||||
|
||||
this.stage = new Stage();
|
||||
this.stage.initModality(Modality.APPLICATION_MODAL);
|
||||
if (owner != null) {
|
||||
this.stage.initOwner(owner);
|
||||
}
|
||||
this.stage.setTitle("CyberCore Signer — Gerenciamento e Seleção de Selos Digitais");
|
||||
this.stage.setResizable(true);
|
||||
this.stage.setMinWidth(840);
|
||||
this.stage.setMinHeight(520);
|
||||
|
||||
initLayout();
|
||||
refreshSealsList();
|
||||
}
|
||||
|
||||
private void initLayout() {
|
||||
VBox root = new VBox(14);
|
||||
root.setPadding(new Insets(18));
|
||||
root.getStyleClass().add("dialog-container");
|
||||
|
||||
// Topo / Cabeçalho
|
||||
HBox header = new HBox(12);
|
||||
header.setAlignment(Pos.CENTER_LEFT);
|
||||
VBox titleBox = new VBox(3);
|
||||
Label lblTitle = new Label("GERENCIAMENTO DE SELOS DIGITAIS");
|
||||
lblTitle.getStyleClass().add("dialog-header");
|
||||
Label lblSub = new Label("Escolha o selo padrão ativo para assinar PDFs, crie novas identidades visuais ou personalize selos instalados.");
|
||||
lblSub.getStyleClass().add("dialog-subtitle");
|
||||
titleBox.getChildren().addAll(lblTitle, lblSub);
|
||||
header.getChildren().addAll(SvgIcons.sealIcon(), titleBox);
|
||||
|
||||
// Painel Principal Dividido: Lista à esquerda, Detalhes e Preview à direita
|
||||
HBox mainContent = new HBox(16);
|
||||
VBox.setVgrow(mainContent, Priority.ALWAYS);
|
||||
|
||||
// --- Coluna Esquerda: Lista de Selos ---
|
||||
VBox leftColumn = new VBox(10);
|
||||
leftColumn.setPrefWidth(330);
|
||||
leftColumn.setMinWidth(300);
|
||||
leftColumn.getStyleClass().add("dialog-box");
|
||||
|
||||
HBox listHeader = new HBox(8);
|
||||
listHeader.setAlignment(Pos.CENTER_LEFT);
|
||||
Label lblListTitle = new Label("Selos Disponíveis no Sistema");
|
||||
lblListTitle.getStyleClass().add("dialog-field-title");
|
||||
listHeader.getChildren().add(lblListTitle);
|
||||
|
||||
listViewSeals = new ListView<>();
|
||||
VBox.setVgrow(listViewSeals, Priority.ALWAYS);
|
||||
listViewSeals.setStyle("-fx-background-color: transparent; -fx-background-radius: 6px;");
|
||||
listViewSeals.setCellFactory(lv -> new SealListCell());
|
||||
listViewSeals.getSelectionModel().selectedItemProperty().addListener((obs, oldV, newV) -> onThemeSelected(newV));
|
||||
|
||||
HBox listActionRow = new HBox(8);
|
||||
listActionRow.setAlignment(Pos.CENTER_LEFT);
|
||||
|
||||
Button btnCreateNew = new Button("+ Novo Selo");
|
||||
btnCreateNew.getStyleClass().add("btn-primary");
|
||||
btnCreateNew.setOnAction(e -> handleCreateNewSeal());
|
||||
HBox.setHgrow(btnCreateNew, Priority.ALWAYS);
|
||||
btnCreateNew.setMaxWidth(Double.MAX_VALUE);
|
||||
|
||||
Button btnImport = new Button("📥 Importar");
|
||||
btnImport.getStyleClass().add("btn-secondary");
|
||||
btnImport.setOnAction(e -> handleImportSeal());
|
||||
HBox.setHgrow(btnImport, Priority.ALWAYS);
|
||||
btnImport.setMaxWidth(Double.MAX_VALUE);
|
||||
|
||||
listActionRow.getChildren().addAll(btnCreateNew, btnImport);
|
||||
|
||||
leftColumn.getChildren().addAll(listHeader, listViewSeals, listActionRow);
|
||||
|
||||
// --- Coluna Direita: Pré-Visualização e Ações do Selo Selecionado ---
|
||||
VBox rightColumn = new VBox(10);
|
||||
rightColumn.setPrefWidth(430);
|
||||
rightColumn.setMinWidth(380);
|
||||
rightColumn.getStyleClass().add("dialog-box");
|
||||
rightColumn.setAlignment(Pos.TOP_CENTER);
|
||||
HBox.setHgrow(rightColumn, Priority.ALWAYS);
|
||||
|
||||
Label lblPreviewHeader = new Label("PRÉ-VISUALIZAÇÃO EM TEMPO REAL");
|
||||
lblPreviewHeader.getStyleClass().add("dialog-field-title");
|
||||
|
||||
lblSelectedName = new Label("Selecione um selo para visualizar");
|
||||
lblSelectedName.setStyle("-fx-font-weight: bold; -fx-font-size: 14px;");
|
||||
|
||||
lblSelectedDesc = new Label("");
|
||||
lblSelectedDesc.setStyle("-fx-font-size: 11.5px; -fx-text-fill: #64748b;");
|
||||
lblSelectedDesc.setWrapText(true);
|
||||
|
||||
previewImageView = new ImageView();
|
||||
previewImageView.setPreserveRatio(true);
|
||||
previewImageView.setFitWidth(380);
|
||||
|
||||
HBox previewContainer = new HBox(previewImageView);
|
||||
previewContainer.setAlignment(Pos.CENTER);
|
||||
previewContainer.setPadding(new Insets(14, 10, 14, 10));
|
||||
previewContainer.setStyle("-fx-background-color: #090d16; -fx-background-radius: 8px; -fx-border-color: #334155; -fx-border-radius: 8px;");
|
||||
VBox.setVgrow(previewContainer, Priority.ALWAYS);
|
||||
|
||||
lblActiveBadge = new Label("");
|
||||
lblActiveBadge.setStyle("-fx-font-weight: bold; -fx-font-size: 12px;");
|
||||
|
||||
lblFeedback = new Label("");
|
||||
lblFeedback.setStyle("-fx-font-size: 12px; -fx-font-weight: bold; -fx-text-fill: #16a34a;");
|
||||
|
||||
// Botões de Ação do Selo Selecionado
|
||||
VBox sealActions = new VBox(8);
|
||||
sealActions.setAlignment(Pos.CENTER);
|
||||
|
||||
btnSetDefault = new Button("✔ Definir como Selo Padrão Ativo");
|
||||
btnSetDefault.getStyleClass().add("btn-primary");
|
||||
btnSetDefault.setMaxWidth(Double.MAX_VALUE);
|
||||
btnSetDefault.setPrefHeight(34);
|
||||
btnSetDefault.setOnAction(e -> handleSetDefault());
|
||||
|
||||
HBox secondaryActions = new HBox(8);
|
||||
secondaryActions.setAlignment(Pos.CENTER);
|
||||
|
||||
btnEditClone = new Button("✏ Clonar");
|
||||
btnEditClone.getStyleClass().add("btn-secondary");
|
||||
btnEditClone.setPrefHeight(32);
|
||||
HBox.setHgrow(btnEditClone, Priority.ALWAYS);
|
||||
btnEditClone.setMaxWidth(Double.MAX_VALUE);
|
||||
btnEditClone.setOnAction(e -> handleEditClone());
|
||||
|
||||
btnExport = new Button("📥 Exportar");
|
||||
btnExport.getStyleClass().add("btn-secondary");
|
||||
btnExport.setPrefHeight(32);
|
||||
HBox.setHgrow(btnExport, Priority.ALWAYS);
|
||||
btnExport.setMaxWidth(Double.MAX_VALUE);
|
||||
btnExport.setOnAction(e -> handleExportSeal());
|
||||
|
||||
btnDelete = new Button("✖ Excluir");
|
||||
btnDelete.getStyleClass().addAll("button", "btn-danger");
|
||||
btnDelete.setAlignment(Pos.CENTER);
|
||||
btnDelete.setPrefHeight(32);
|
||||
HBox.setHgrow(btnDelete, Priority.ALWAYS);
|
||||
btnDelete.setMaxWidth(Double.MAX_VALUE);
|
||||
btnDelete.setOnAction(e -> handleDeleteCustomSeal());
|
||||
|
||||
secondaryActions.getChildren().addAll(btnEditClone, btnExport, btnDelete);
|
||||
sealActions.getChildren().addAll(btnSetDefault, secondaryActions);
|
||||
|
||||
rightColumn.getChildren().addAll(lblPreviewHeader, lblSelectedName, lblSelectedDesc, previewContainer, lblActiveBadge, lblFeedback, sealActions);
|
||||
|
||||
mainContent.getChildren().addAll(leftColumn, rightColumn);
|
||||
|
||||
// Rodapé
|
||||
HBox footer = new HBox(12);
|
||||
footer.setAlignment(Pos.CENTER_RIGHT);
|
||||
|
||||
Button btnClose = new Button(" Fechar", SvgIcons.closeIcon(13));
|
||||
btnClose.getStyleClass().add("btn-secondary");
|
||||
btnClose.setPrefWidth(95);
|
||||
btnClose.setOnAction(e -> stage.close());
|
||||
|
||||
footer.getChildren().add(btnClose);
|
||||
|
||||
root.getChildren().addAll(header, mainContent, footer);
|
||||
|
||||
Scene scene = new Scene(root, 880, 560);
|
||||
ThemeManager.applyTheme(scene);
|
||||
stage.setScene(scene);
|
||||
}
|
||||
|
||||
private void refreshSealsList() {
|
||||
List<PdfSealTheme> themes = PdfSealThemeRegistry.listThemes();
|
||||
listViewSeals.setItems(FXCollections.observableArrayList(themes));
|
||||
|
||||
// Seleciona o selo padrão ativo ou o primeiro disponível
|
||||
PdfSealTheme toSelect = themes.stream()
|
||||
.filter(t -> t.id().equalsIgnoreCase(currentDefaultThemeId))
|
||||
.findFirst()
|
||||
.orElse(themes.isEmpty() ? null : themes.get(0));
|
||||
|
||||
if (toSelect != null) {
|
||||
listViewSeals.getSelectionModel().select(toSelect);
|
||||
}
|
||||
}
|
||||
|
||||
private void onThemeSelected(PdfSealTheme theme) {
|
||||
lblFeedback.setText("");
|
||||
if (theme == null) {
|
||||
lblSelectedName.setText("Nenhum selo selecionado");
|
||||
lblSelectedDesc.setText("");
|
||||
lblActiveBadge.setText("");
|
||||
btnSetDefault.setDisable(true);
|
||||
btnEditClone.setDisable(true);
|
||||
btnDelete.setDisable(true);
|
||||
btnExport.setDisable(true);
|
||||
previewImageView.setImage(null);
|
||||
return;
|
||||
}
|
||||
|
||||
lblSelectedName.setText(theme.name() + " (" + theme.id() + ")");
|
||||
lblSelectedDesc.setText(theme.description() != null ? theme.description() : "Selo visual para assinatura");
|
||||
|
||||
boolean isDefault = theme.id().equalsIgnoreCase(currentDefaultThemeId);
|
||||
if (isDefault) {
|
||||
lblActiveBadge.setText("★ ESTE É O SELO PADRÃO ATIVO PARA TODAS AS ASSINATURAS");
|
||||
lblActiveBadge.setStyle("-fx-text-fill: #38bdf8; -fx-font-weight: bold; -fx-font-size: 11.5px;");
|
||||
btnSetDefault.setText("★ Selo Padrão Ativo no Sistema");
|
||||
btnSetDefault.setDisable(true);
|
||||
btnSetDefault.setStyle("-fx-opacity: 0.95; -fx-background-color: #0369a1; -fx-text-fill: #ffffff; -fx-font-weight: bold;");
|
||||
} else {
|
||||
lblActiveBadge.setText("Selo disponível para uso.");
|
||||
lblActiveBadge.setStyle("-fx-text-fill: #94a3b8; -fx-font-size: 11.5px;");
|
||||
btnSetDefault.setText("✔ Definir como Selo Padrão Ativo");
|
||||
btnSetDefault.setDisable(false);
|
||||
btnSetDefault.setStyle("");
|
||||
}
|
||||
|
||||
btnEditClone.setDisable(false);
|
||||
btnExport.setDisable(false);
|
||||
btnDelete.setDisable(!theme.isCustom());
|
||||
|
||||
renderThemePreview(theme);
|
||||
}
|
||||
|
||||
private void renderThemePreview(PdfSealTheme theme) {
|
||||
Platform.runLater(() -> {
|
||||
try {
|
||||
PdfSealData sampleData = new PdfSealData(
|
||||
"Dr. Carlos Alberto de Oliveira Santos Júnior",
|
||||
"CPF: 12345678900",
|
||||
null,
|
||||
"AC CyberCore JUS v5",
|
||||
"8A7F4B2C91D0E3F5",
|
||||
"Assinatura de Documento Digital",
|
||||
"São Paulo, SP",
|
||||
theme.defaultCpfMode() != null ? theme.defaultCpfMode() : CpfDisplayMode.FULL,
|
||||
theme.defaultIncludeDateTime(),
|
||||
theme.formatCustomField()
|
||||
);
|
||||
|
||||
byte[] pngBytes = PdfSealRenderer.renderToPngBytes(theme, sampleData);
|
||||
previewImageView.setImage(new Image(new ByteArrayInputStream(pngBytes)));
|
||||
} catch (Exception ex) {
|
||||
logger.warn("Erro ao renderizar preview do selo: {}", ex.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void handleSetDefault() {
|
||||
PdfSealTheme selected = listViewSeals.getSelectionModel().getSelectedItem();
|
||||
if (selected == null) return;
|
||||
|
||||
try {
|
||||
AgentConfiguration currentConfig = configService.loadConfiguration();
|
||||
AgentConfiguration newConfig = new AgentConfiguration(
|
||||
currentConfig.requireConfirmation(),
|
||||
currentConfig.alwaysPromptPin(),
|
||||
currentConfig.autoStart(),
|
||||
selected.id(),
|
||||
currentConfig.defaultCpfDisplayMode(),
|
||||
currentConfig.defaultIncludeDateTime(),
|
||||
currentConfig.serverPort()
|
||||
);
|
||||
configService.saveConfiguration(newConfig);
|
||||
this.currentDefaultThemeId = selected.id();
|
||||
|
||||
lblFeedback.setText("✓ Selo '" + selected.name() + "' definido como padrão com sucesso!");
|
||||
listViewSeals.refresh();
|
||||
onThemeSelected(selected);
|
||||
} catch (Exception ex) {
|
||||
showError("Falha ao salvar configuração: " + ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void handleCreateNewSeal() {
|
||||
new SealDesignerDialog(stage, createdSeal -> {
|
||||
refreshSealsList();
|
||||
listViewSeals.getSelectionModel().select(createdSeal);
|
||||
lblFeedback.setText("✓ Novo selo '" + createdSeal.name() + "' instalado!");
|
||||
}).show();
|
||||
}
|
||||
|
||||
private void handleEditClone() {
|
||||
PdfSealTheme selected = listViewSeals.getSelectionModel().getSelectedItem();
|
||||
if (selected == null) return;
|
||||
|
||||
new SealDesignerDialog(stage, selected, updatedSeal -> {
|
||||
refreshSealsList();
|
||||
listViewSeals.getSelectionModel().select(updatedSeal);
|
||||
lblFeedback.setText("✓ Selo '" + updatedSeal.name() + "' salvo com sucesso!");
|
||||
}).show();
|
||||
}
|
||||
|
||||
private void handleDeleteCustomSeal() {
|
||||
PdfSealTheme selected = listViewSeals.getSelectionModel().getSelectedItem();
|
||||
if (selected == null || !selected.isCustom()) return;
|
||||
|
||||
Alert confirm = new Alert(Alert.AlertType.CONFIRMATION);
|
||||
confirm.setTitle("Confirmar Exclusão");
|
||||
confirm.setHeaderText(null);
|
||||
confirm.setContentText("Deseja realmente remover o selo personalizado '" + selected.name() + "'?");
|
||||
confirm.initOwner(stage);
|
||||
ThemeManager.applyTheme(confirm.getDialogPane().getScene());
|
||||
|
||||
Optional<ButtonType> result = confirm.showAndWait();
|
||||
if (result.isPresent() && result.get() == ButtonType.OK) {
|
||||
try {
|
||||
PdfSealThemeRegistry.uninstallCustomTheme(selected.id());
|
||||
if (selected.id().equalsIgnoreCase(currentDefaultThemeId)) {
|
||||
currentDefaultThemeId = PdfSealThemeRegistry.DEFAULT_THEME_ID;
|
||||
}
|
||||
refreshSealsList();
|
||||
} catch (Exception ex) {
|
||||
showError("Falha ao excluir selo: " + ex.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void handleImportSeal() {
|
||||
FileChooser chooser = new FileChooser();
|
||||
chooser.setTitle("Selecionar Arquivo de Selo (.json)");
|
||||
chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter("Arquivos JSON de Selo (*.json)", "*.json"));
|
||||
|
||||
File file = chooser.showOpenDialog(stage);
|
||||
if (file != null) {
|
||||
try {
|
||||
PdfSealTheme imported = PdfSealThemeRegistry.getStorage().importSeal(file.toPath());
|
||||
PdfSealThemeRegistry.registerTheme(imported);
|
||||
refreshSealsList();
|
||||
listViewSeals.getSelectionModel().select(imported);
|
||||
lblFeedback.setText("✓ Selo '" + imported.name() + "' importado e instalado com sucesso!");
|
||||
} catch (Exception ex) {
|
||||
showError("Falha ao importar selo: " + ex.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void handleExportSeal() {
|
||||
PdfSealTheme selected = listViewSeals.getSelectionModel().getSelectedItem();
|
||||
if (selected == null) return;
|
||||
|
||||
FileChooser chooser = new FileChooser();
|
||||
chooser.setTitle("Exportar Selo Digital (.json)");
|
||||
chooser.setInitialFileName(selected.id() + ".json");
|
||||
chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter("Arquivos JSON de Selo (*.json)", "*.json"));
|
||||
|
||||
File file = chooser.showSaveDialog(stage);
|
||||
if (file != null) {
|
||||
try {
|
||||
PdfSealThemeRegistry.getStorage().exportSeal(selected, file.toPath());
|
||||
lblFeedback.setText("✓ Selo exportado com sucesso para " + file.getName());
|
||||
} catch (Exception ex) {
|
||||
showError("Falha ao exportar selo: " + ex.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void showError(String msg) {
|
||||
Alert a = new Alert(Alert.AlertType.ERROR);
|
||||
a.setTitle("Erro");
|
||||
a.setHeaderText(null);
|
||||
a.setContentText(msg);
|
||||
a.initOwner(stage);
|
||||
ThemeManager.applyTheme(a.getDialogPane().getScene());
|
||||
a.showAndWait();
|
||||
}
|
||||
|
||||
public void show() {
|
||||
stage.centerOnScreen();
|
||||
stage.show();
|
||||
}
|
||||
|
||||
public void showAndWait() {
|
||||
stage.centerOnScreen();
|
||||
stage.showAndWait();
|
||||
}
|
||||
|
||||
/**
|
||||
* Célula customizada para a listagem visual dos selos.
|
||||
*/
|
||||
private class SealListCell extends ListCell<PdfSealTheme> {
|
||||
@Override
|
||||
protected void updateItem(PdfSealTheme item, boolean empty) {
|
||||
super.updateItem(item, empty);
|
||||
if (empty || item == null) {
|
||||
setGraphic(null);
|
||||
setText(null);
|
||||
} else {
|
||||
HBox card = new HBox(10);
|
||||
card.setAlignment(Pos.CENTER_LEFT);
|
||||
card.setPadding(new Insets(8, 10, 8, 10));
|
||||
|
||||
VBox textBox = new VBox(2);
|
||||
Label lblName = new Label(item.name());
|
||||
lblName.setStyle("-fx-font-weight: bold; -fx-font-size: 12.5px;");
|
||||
lblName.maxWidthProperty().bind(listViewSeals.widthProperty().subtract(120));
|
||||
lblName.setWrapText(true);
|
||||
|
||||
String desc = (item.description() != null && !item.description().isBlank())
|
||||
? item.description() : "Identidade visual de assinatura";
|
||||
Label lblDesc = new Label(desc);
|
||||
lblDesc.setStyle("-fx-font-size: 11px; -fx-text-fill: #64748b;");
|
||||
lblDesc.maxWidthProperty().bind(listViewSeals.widthProperty().subtract(120));
|
||||
lblDesc.setWrapText(true);
|
||||
|
||||
textBox.getChildren().addAll(lblName, lblDesc);
|
||||
HBox.setHgrow(textBox, Priority.ALWAYS);
|
||||
|
||||
VBox badgesBox = new VBox(4);
|
||||
badgesBox.setAlignment(Pos.CENTER_RIGHT);
|
||||
|
||||
boolean isDefault = item.id().equalsIgnoreCase(currentDefaultThemeId);
|
||||
if (isDefault) {
|
||||
Label badgeDefault = new Label("★ PADRÃO");
|
||||
badgeDefault.setStyle("-fx-background-color: #0284c7; -fx-text-fill: #ffffff; -fx-font-size: 9.5px; -fx-font-weight: bold; -fx-padding: 2px 6px; -fx-background-radius: 4px;");
|
||||
badgesBox.getChildren().add(badgeDefault);
|
||||
}
|
||||
|
||||
Label badgeType = new Label(item.isCustom() ? "Personalizado" : "Sistema");
|
||||
badgeType.setStyle(item.isCustom()
|
||||
? "-fx-background-color: #fef3c7; -fx-text-fill: #b45309; -fx-font-size: 9.5px; -fx-padding: 2px 6px; -fx-background-radius: 4px;"
|
||||
: "-fx-background-color: #e0f2fe; -fx-text-fill: #0369a1; -fx-font-size: 9.5px; -fx-padding: 2px 6px; -fx-background-radius: 4px;");
|
||||
badgesBox.getChildren().add(badgeType);
|
||||
|
||||
card.getChildren().addAll(textBox, badgesBox);
|
||||
setGraphic(card);
|
||||
setText(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user