feat(pdf): centraliza configuracao de selo padrao e adiciona armazenamento seguro de PIN
This commit is contained in:
+17
@@ -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.
|
||||
*/
|
||||
|
||||
+269
@@ -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");
|
||||
}
|
||||
}
|
||||
+77
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user