feat(pdf): centraliza configuracao de selo padrao e adiciona armazenamento seguro de PIN

This commit is contained in:
Claudecio Martins
2026-10-11 10:00:46 -03:00
parent ccfa358f97
commit 7f6d8a4a08
14 changed files with 1766 additions and 684 deletions
@@ -66,6 +66,8 @@ public class PdfSigningService {
this.certificateManager = Objects.requireNonNull(certificateManager, "CertificateManager não pode ser nulo.");
}
public record PageDimensions(float width, float height) {}
/**
* Retorna a quantidade total de páginas contidas no documento PDF.
*/
@@ -76,6 +78,21 @@ public class PdfSigningService {
}
}
/**
* Retorna as dimensões em pontos (pt) da página informada do documento PDF.
*/
public static PageDimensions getPageDimensions(byte[] pdfBytes, int pageIndex0Based) throws IOException {
Objects.requireNonNull(pdfBytes, "Bytes do PDF não podem ser nulos.");
try (PDDocument doc = Loader.loadPDF(pdfBytes)) {
if (pageIndex0Based < 0 || pageIndex0Based >= doc.getNumberOfPages()) {
throw new IllegalArgumentException("Índice de página inválido: " + pageIndex0Based + ". Total: " + doc.getNumberOfPages());
}
PDPage page = doc.getPage(pageIndex0Based);
PDRectangle mediaBox = page.getMediaBox();
return new PageDimensions(mediaBox.getWidth(), mediaBox.getHeight());
}
}
/**
* Renderiza uma página específica do PDF para BufferedImage na resolução DPI informada.
*/
@@ -0,0 +1,269 @@
package br.com.cybercore.signer.core.security;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.PBEKeySpec;
import javax.crypto.spec.SecretKeySpec;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.SecureRandom;
import java.security.spec.KeySpec;
import java.util.Arrays;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
/**
* Armazenamento local seguro e criptografado de PINs/senhas de certificados.
* <p>
* Utiliza criptografia simétrica AES-256-GCM com chave derivada exclusivamente
* a partir de atributos do usuário e da máquina local (PBKDF2WithHmacSHA256).
* Os dados são gravados com permissões restritas em {@code ~/.cybercore/secure-pins.dat}.
*/
public final class SecurePinStorage {
private static final Logger logger = LoggerFactory.getLogger(SecurePinStorage.class);
private static final String FILE_NAME = "secure-pins.dat";
private static final String SALT_FILE_NAME = "secure-pins.salt";
private static final int GCM_IV_LENGTH = 12;
private static final int GCM_TAG_LENGTH = 128;
private static final int PBKDF2_ITERATIONS = 65536;
private static final int KEY_LENGTH_BITS = 256;
private static final SecurePinStorage INSTANCE = new SecurePinStorage();
private final Path storageDirectory;
private final Path dataFile;
private final Path saltFile;
private final ObjectMapper objectMapper;
private final Map<String, char[]> memoryCache = new ConcurrentHashMap<>();
public static SecurePinStorage getInstance() {
return INSTANCE;
}
public SecurePinStorage() {
this(resolveDefaultDirectory());
}
public SecurePinStorage(Path storageDirectory) {
this.storageDirectory = Objects.requireNonNull(storageDirectory);
this.dataFile = storageDirectory.resolve(FILE_NAME);
this.saltFile = storageDirectory.resolve(SALT_FILE_NAME);
this.objectMapper = new ObjectMapper();
loadFromDisk();
}
private static Path resolveDefaultDirectory() {
String userHome = System.getProperty("user.home", ".");
return Path.of(userHome, ".cybercore");
}
/**
* Armazena de forma persistente e criptografada o PIN associado ao ID do certificado.
*/
public synchronized void savePin(String certId, char[] pin) {
Objects.requireNonNull(certId, "certId não pode ser nulo");
if (pin == null || pin.length == 0) {
removePin(certId);
return;
}
memoryCache.put(certId, pin.clone());
PinSessionManager.getInstance().savePin(certId, pin);
persistToDisk();
logger.info("PIN do certificado '{}' armazenado com segurança neste computador.", certId);
}
/**
* Retorna o PIN armazenado para o certificado ou {@code null} se não houver.
*/
public synchronized char[] getPin(String certId) {
if (certId == null) {
return null;
}
// 1. Tenta cache em memória
char[] cached = memoryCache.get(certId);
if (cached != null) {
return cached.clone();
}
// 2. Tenta carregar do PinSessionManager
char[] sessionPin = PinSessionManager.getInstance().getPin(certId);
if (sessionPin != null) {
memoryCache.put(certId, sessionPin.clone());
return sessionPin.clone();
}
return null;
}
/**
* Verifica se existe um PIN armazenado para o certificado.
*/
public synchronized boolean hasPin(String certId) {
if (certId == null) {
return false;
}
return memoryCache.containsKey(certId) || PinSessionManager.getInstance().hasPin(certId);
}
/**
* Remove o PIN salvo para o certificado e zera a memória.
*/
public synchronized void removePin(String certId) {
if (certId == null) {
return;
}
char[] old = memoryCache.remove(certId);
if (old != null) {
Arrays.fill(old, ' ');
}
PinSessionManager.getInstance().clearPin(certId);
persistToDisk();
logger.info("PIN do certificado '{}' removido do armazenamento seguro.", certId);
}
/**
* Remove todos os PINs armazenados em disco e zera todas as memórias.
*/
public synchronized void clearAll() {
for (char[] pin : memoryCache.values()) {
if (pin != null) {
Arrays.fill(pin, ' ');
}
}
memoryCache.clear();
PinSessionManager.getInstance().clearAll();
try {
Files.deleteIfExists(dataFile);
logger.info("Arquivo de credenciais criptografadas removido com sucesso.");
} catch (Exception e) {
logger.warn("Falha ao remover arquivo de credenciais: {}", e.getMessage());
}
}
public synchronized int getStoredCount() {
return memoryCache.size();
}
// =========================================================================
// CRIPTOGRAFIA AES-256-GCM E DERIVAÇÃO DE CHAVE
// =========================================================================
private void persistToDisk() {
try {
Files.createDirectories(storageDirectory);
if (memoryCache.isEmpty()) {
Files.deleteIfExists(dataFile);
return;
}
Map<String, String> plainMap = new HashMap<>();
for (Map.Entry<String, char[]> entry : memoryCache.entrySet()) {
plainMap.put(entry.getKey(), new String(entry.getValue()));
}
byte[] jsonBytes = objectMapper.writeValueAsBytes(plainMap);
byte[] salt = getOrCreateSalt();
SecretKey secretKey = deriveKey(salt);
byte[] iv = new byte[GCM_IV_LENGTH];
new SecureRandom().nextBytes(iv);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
cipher.init(Cipher.ENCRYPT_MODE, secretKey, spec);
byte[] ciphertext = cipher.doFinal(jsonBytes);
// Grava: [12 bytes IV] + [ciphertext + tag]
try (FileOutputStream fos = new FileOutputStream(dataFile.toFile())) {
fos.write(iv);
fos.write(ciphertext);
}
} catch (Exception e) {
logger.error("Erro ao persistir PINs criptografados no disco: {}", e.getMessage(), e);
}
}
private void loadFromDisk() {
if (!Files.exists(dataFile) || !Files.exists(saltFile)) {
return;
}
try {
byte[] allBytes = Files.readAllBytes(dataFile);
if (allBytes.length <= GCM_IV_LENGTH) {
return;
}
byte[] iv = Arrays.copyOfRange(allBytes, 0, GCM_IV_LENGTH);
byte[] ciphertext = Arrays.copyOfRange(allBytes, GCM_IV_LENGTH, allBytes.length);
byte[] salt = Files.readAllBytes(saltFile);
SecretKey secretKey = deriveKey(salt);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
cipher.init(Cipher.DECRYPT_MODE, secretKey, spec);
byte[] plainBytes = cipher.doFinal(ciphertext);
Map<String, String> plainMap = objectMapper.readValue(plainBytes, new TypeReference<>() {});
memoryCache.clear();
for (Map.Entry<String, String> entry : plainMap.entrySet()) {
char[] pin = entry.getValue().toCharArray();
memoryCache.put(entry.getKey(), pin);
PinSessionManager.getInstance().savePin(entry.getKey(), pin);
}
logger.info("Carregados {} PIN(s) do armazenamento seguro local.", memoryCache.size());
} catch (Exception e) {
logger.warn("Não foi possível carregar credenciais salvas em disco: {}", e.getMessage());
}
}
private byte[] getOrCreateSalt() throws Exception {
if (Files.exists(saltFile)) {
return Files.readAllBytes(saltFile);
}
byte[] salt = new byte[32];
new SecureRandom().nextBytes(salt);
Files.write(saltFile, salt);
return salt;
}
private SecretKey deriveKey(byte[] salt) throws Exception {
// Combina identificadores estáveis exclusivos da máquina e usuário
String hostIdentity = String.join(":",
System.getProperty("user.name", "unknown"),
System.getProperty("user.home", "unknown"),
System.getProperty("os.name", "unknown"),
System.getProperty("os.arch", "unknown"),
"cybercore-signer-hardware-pin-binding"
);
KeySpec spec = new PBEKeySpec(hostIdentity.toCharArray(), salt, PBKDF2_ITERATIONS, KEY_LENGTH_BITS);
SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
byte[] keyBytes = factory.generateSecret(spec).getEncoded();
return new SecretKeySpec(keyBytes, "AES");
}
}
@@ -0,0 +1,77 @@
package br.com.cybercore.signer.core.security;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class SecurePinStorageTest {
@TempDir
Path tempDir;
private SecurePinStorage storage;
@BeforeEach
void setUp() {
storage = new SecurePinStorage(tempDir);
}
@AfterEach
void tearDown() {
storage.clearAll();
}
@Test
void shouldSaveAndRetrievePinCorrectly() {
String certId = "cert-test-123";
char[] pin = "123456".toCharArray();
storage.savePin(certId, pin);
assertThat(storage.hasPin(certId)).isTrue();
assertThat(storage.getPin(certId)).isEqualTo(pin);
}
@Test
void shouldPersistAndReloadFromDisk() {
String certId = "token-a3-999";
char[] pin = "senhaForte987".toCharArray();
storage.savePin(certId, pin);
// Cria nova instância apontando para o mesmo diretório
SecurePinStorage reloaded = new SecurePinStorage(tempDir);
assertThat(reloaded.hasPin(certId)).isTrue();
assertThat(reloaded.getPin(certId)).isEqualTo(pin);
}
@Test
void shouldRemoveSpecificPin() {
storage.savePin("cert-1", "1111".toCharArray());
storage.savePin("cert-2", "2222".toCharArray());
storage.removePin("cert-1");
assertThat(storage.hasPin("cert-1")).isFalse();
assertThat(storage.getPin("cert-1")).isNull();
assertThat(storage.hasPin("cert-2")).isTrue();
}
@Test
void shouldClearAllPins() {
storage.savePin("cert-1", "1111".toCharArray());
storage.savePin("cert-2", "2222".toCharArray());
storage.clearAll();
assertThat(storage.hasPin("cert-1")).isFalse();
assertThat(storage.hasPin("cert-2")).isFalse();
assertThat(storage.getStoredCount()).isEqualTo(0);
}
}