fix: enhance security with strict file permissions, pin isolation, and robust protocol validations

This commit is contained in:
Claudecio Martins
2026-10-11 21:47:35 -03:00
parent f4adc99a01
commit 64ca13b3a1
11 changed files with 336 additions and 93 deletions
+1 -1
View File
@@ -55,7 +55,7 @@ echo -e "${COLOR_RESET}"
# 1. Verificação e Auto-Detecção do Java (JDK 21+) # 1. Verificação e Auto-Detecção do Java (JDK 21+)
if ! command -v java &>/dev/null; then if ! command -v java &>/dev/null; then
POSSIBLE_JDKS=( POSSIBLE_JDKS=(
"/home/claudecio/.jdks/jdk-25.0.4.1+1" "${HOME}/.jdks/jdk-25.0.4.1+1"
"/usr/lib/jvm/java-25-openjdk-amd64" "/usr/lib/jvm/java-25-openjdk-amd64"
"/usr/lib/jvm/java-21-openjdk-amd64" "/usr/lib/jvm/java-21-openjdk-amd64"
"/usr/lib/jvm/default-java" "/usr/lib/jvm/default-java"
@@ -218,16 +218,20 @@ public class CcspServer {
appId = getQueryParam(uri, "application_id"); appId = getQueryParam(uri, "application_id");
} }
// Verifica autorização se app_id e origin forem informados // Exige obrigatoriamente a identificação da aplicação para listagem de certificados
if (appId != null && !appId.isBlank()) { if (appId == null || appId.isBlank()) {
boolean authorized = applicationRegistry.isAuthorized(appId, origin); sendErrorResponse(exchange, 401, CcspErrorCode.APPLICATION_NOT_AUTHORIZED.name(),
if (!authorized) { "application_id (via query param ou cabeçalho X-CyberCore-AppId) é obrigatório.");
boolean approved = promptApplicationAuthorization(origin, "Aplicação Web", appId); return;
if (!approved) { }
sendErrorResponse(exchange, 403, CcspErrorCode.APPLICATION_NOT_AUTHORIZED.name(),
"Aplicação não autorizada pelo usuário."); boolean authorized = applicationRegistry.isAuthorized(appId, origin);
return; 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;
} }
} }
@@ -350,14 +354,17 @@ public class CcspServer {
return; return;
} }
// 3. Validação anti-replay (Nonce) // 3. Validação anti-replay (Nonce obrigatório)
if (request.nonce() != null && !request.nonce().isBlank()) { if (request.nonce() == null || request.nonce().isBlank()) {
boolean accepted = nonceManager.registerNonce(request.nonce()); sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.REPLAY_DETECTED,
if (!accepted) { "O parâmetro 'nonce' é obrigatório para proteção contra replay attack.");
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.REPLAY_DETECTED, return;
"Requisição duplicada detectada (replay prevenido)."); }
return; 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 // 4. Validação de autorização da aplicação
@@ -520,13 +527,17 @@ public class CcspServer {
return; return;
} }
if (request.nonce() != null && !request.nonce().isBlank()) { // Validação anti-replay (Nonce obrigatório)
boolean accepted = nonceManager.registerNonce(request.nonce()); if (request.nonce() == null || request.nonce().isBlank()) {
if (!accepted) { sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.REPLAY_DETECTED,
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.REPLAY_DETECTED, "O parâmetro 'nonce' é obrigatório para proteção contra replay attack.");
"Requisição duplicada detectada (replay prevenido)."); return;
return; }
} boolean accepted = nonceManager.registerNonce(request.nonce());
if (!accepted) {
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.REPLAY_DETECTED,
"Requisição duplicada detectada (replay prevenido).");
return;
} }
String appId = request.applicationId(); String appId = request.applicationId();
@@ -735,13 +746,17 @@ public class CcspServer {
return; return;
} }
if (request.nonce() != null && !request.nonce().isBlank()) { // Validação anti-replay (Nonce obrigatório)
boolean accepted = nonceManager.registerNonce(request.nonce()); if (request.nonce() == null || request.nonce().isBlank()) {
if (!accepted) { sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.REPLAY_DETECTED,
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.REPLAY_DETECTED, "O parâmetro 'nonce' é obrigatório para proteção contra replay attack.");
"Requisição duplicada detectada (replay prevenido)."); return;
return; }
} boolean accepted = nonceManager.registerNonce(request.nonce());
if (!accepted) {
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.REPLAY_DETECTED,
"Requisição duplicada detectada (replay prevenido).");
return;
} }
String appId = request.applicationId(); String appId = request.applicationId();
@@ -1032,25 +1047,53 @@ public class CcspServer {
} }
private boolean handleCors(HttpExchange exchange) throws IOException { private boolean handleCors(HttpExchange exchange) throws IOException {
String path = exchange.getRequestURI().getPath();
String origin = getOrigin(exchange); String origin = getOrigin(exchange);
if (origin == null || origin.isBlank()) { if (origin == null || origin.isBlank()) {
origin = "*"; if ("OPTIONS".equalsIgnoreCase(exchange.getRequestMethod())) {
exchange.sendResponseHeaders(204, -1);
exchange.close();
return true;
}
return false;
} }
exchange.getResponseHeaders().set("Access-Control-Allow-Origin", origin); // Determina se a origem ou rota é elegível para CORS
exchange.getResponseHeaders().set("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); boolean isHandshakePath = path.endsWith("/status") || path.endsWith("/authorize");
exchange.getResponseHeaders().set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-CyberCore-AppId, X-CyberCore-Nonce, Accept"); boolean isAuthorizedOrigin = applicationRegistry.findByOrigin(origin).isPresent() || isLocalOrigin(origin);
exchange.getResponseHeaders().set("Access-Control-Allow-Private-Network", "true");
exchange.getResponseHeaders().set("Access-Control-Max-Age", "86400"); if (isHandshakePath || isAuthorizedOrigin) {
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, Accept");
exchange.getResponseHeaders().set("Access-Control-Allow-Private-Network", "true");
exchange.getResponseHeaders().set("Access-Control-Max-Age", "86400");
}
if ("OPTIONS".equalsIgnoreCase(exchange.getRequestMethod())) { if ("OPTIONS".equalsIgnoreCase(exchange.getRequestMethod())) {
exchange.sendResponseHeaders(204, -1); if (isHandshakePath || isAuthorizedOrigin) {
exchange.sendResponseHeaders(204, -1);
} else {
exchange.sendResponseHeaders(403, -1);
}
exchange.close(); exchange.close();
return true; return true;
} }
return false; return false;
} }
private boolean isLocalOrigin(String origin) {
if (origin == null || origin.isBlank()) return false;
try {
URI uri = URI.create(origin);
String host = uri.getHost();
return "localhost".equalsIgnoreCase(host) || "127.0.0.1".equals(host);
} catch (Exception e) {
return false;
}
}
private String getOrigin(HttpExchange exchange) { private String getOrigin(HttpExchange exchange) {
String origin = exchange.getRequestHeaders().getFirst("Origin"); String origin = exchange.getRequestHeaders().getFirst("Origin");
if (origin == null || origin.isBlank()) { if (origin == null || origin.isBlank()) {
@@ -3,6 +3,8 @@ package br.com.cybercore.signer.core.security;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Arrays; import java.util.Arrays;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
@@ -12,15 +14,19 @@ import java.util.concurrent.ConcurrentHashMap;
* Gerenciador de PIN e credenciais voláteis de sessão do CyberCore Signer. * 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 * 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". * exclusivamente quando o usuário autorizar explicitamente a opção "Salvar PIN na sessão".
* Os PINs são escopados por aplicação web (applicationId + certId) e possuem expiração por inatividade (TTL de 15 min).
* Todas as senhas e PINs são sobrescritos com espaços em branco (' ') no descarte. * Todas as senhas e PINs são sobrescritos com espaços em branco (' ') no descarte.
*/ */
public class PinSessionManager { public class PinSessionManager {
private static final Logger logger = LoggerFactory.getLogger(PinSessionManager.class); private static final Logger logger = LoggerFactory.getLogger(PinSessionManager.class);
private static final long SESSION_TTL_MINUTES = 15;
private static final PinSessionManager INSTANCE = new PinSessionManager(); private static final PinSessionManager INSTANCE = new PinSessionManager();
private final Map<String, char[]> sessionPins = new ConcurrentHashMap<>(); private record PinEntry(char[] pin, Instant lastAccessedAt) {}
private final Map<String, PinEntry> sessionPins = new ConcurrentHashMap<>();
private volatile boolean alwaysPromptPin = false; private volatile boolean alwaysPromptPin = false;
public PinSessionManager() { public PinSessionManager() {
@@ -30,63 +36,107 @@ public class PinSessionManager {
return INSTANCE; return INSTANCE;
} }
private String buildKey(String applicationId, String certId) {
String appId = (applicationId != null && !applicationId.isBlank()) ? applicationId.trim() : "default";
return appId + "::" + certId.trim();
}
/** /**
* Salva o PIN de um certificado/token para a sessão atual em memória volátil. * Salva o PIN associado a uma aplicação específica e certificado.
*/ */
public synchronized void savePin(String certId, char[] pin) { public synchronized void savePin(String applicationId, String certId, char[] pin) {
Objects.requireNonNull(certId, "certId must not be null"); Objects.requireNonNull(certId, "certId must not be null");
String key = buildKey(applicationId, certId);
if (pin == null || pin.length == 0) { if (pin == null || pin.length == 0) {
clearPin(certId); clearPin(applicationId, certId);
return; return;
} }
// Limpa PIN anterior caso exista clearPin(applicationId, certId);
clearPin(certId); sessionPins.put(key, new PinEntry(pin.clone(), Instant.now()));
logger.debug("PIN salvo em sessão para app '{}' e cert '{}'. Tamanho: {} dígitos", applicationId, certId, pin.length);
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. * Salva o PIN no escopo padrão (compatibilidade).
*/ */
public synchronized char[] getPin(String certId) { public synchronized void savePin(String certId, char[] pin) {
savePin("default", certId, pin);
}
/**
* Recupera o PIN salvo em sessão para a aplicação e certificado especificados.
*/
public synchronized char[] getPin(String applicationId, String certId) {
if (certId == null || alwaysPromptPin) { if (certId == null || alwaysPromptPin) {
return null; return null;
} }
char[] stored = sessionPins.get(certId); String key = buildKey(applicationId, certId);
return stored != null ? stored.clone() : null; PinEntry entry = sessionPins.get(key);
if (entry == null) {
return null;
}
// Verifica expiração por inatividade (TTL)
if (Instant.now().isAfter(entry.lastAccessedAt().plus(SESSION_TTL_MINUTES, ChronoUnit.MINUTES))) {
clearPin(applicationId, certId);
logger.info("PIN da sessão expirou por inatividade (TTL {} min) para app '{}' e cert '{}'.", SESSION_TTL_MINUTES, applicationId, certId);
return null;
}
// Atualiza timestamp de último acesso
sessionPins.put(key, new PinEntry(entry.pin(), Instant.now()));
return entry.pin().clone();
} }
/** /**
* Verifica se existe um PIN válido salvo nesta sessão para o certificado. * Recupera o PIN no escopo padrão (compatibilidade).
*/
public synchronized char[] getPin(String certId) {
return getPin("default", certId);
}
/**
* Verifica se existe um PIN válido salvo nesta sessão para a aplicação e certificado.
*/
public synchronized boolean hasPin(String applicationId, String certId) {
return getPin(applicationId, certId) != null;
}
/**
* Verifica se existe um PIN no escopo padrão (compatibilidade).
*/ */
public synchronized boolean hasPin(String certId) { public synchronized boolean hasPin(String certId) {
if (certId == null || alwaysPromptPin) { return hasPin("default", certId);
return false;
}
return sessionPins.containsKey(certId);
} }
/** /**
* Remove o PIN salvo para um certificado específico, zerando a memória. * Remove o PIN salvo para uma aplicação e certificado específicos, zerando a memória.
*/ */
public synchronized void clearPin(String certId) { public synchronized void clearPin(String applicationId, String certId) {
if (certId != null) { if (certId != null) {
char[] old = sessionPins.remove(certId); String key = buildKey(applicationId, certId);
if (old != null) { PinEntry old = sessionPins.remove(key);
Arrays.fill(old, ' '); if (old != null && old.pin() != null) {
Arrays.fill(old.pin(), ' ');
} }
} }
} }
/**
* Remove o PIN no escopo padrão (compatibilidade).
*/
public synchronized void clearPin(String certId) {
clearPin("default", certId);
}
/** /**
* Limpa imediatamente todos os PINs em memória e zera seus caracteres. * Limpa imediatamente todos os PINs em memória e zera seus caracteres.
*/ */
public synchronized void clearAll() { public synchronized void clearAll() {
for (char[] pin : sessionPins.values()) { for (PinEntry entry : sessionPins.values()) {
if (pin != null) { if (entry != null && entry.pin() != null) {
Arrays.fill(pin, ' '); Arrays.fill(entry.pin(), ' ');
} }
} }
sessionPins.clear(); sessionPins.clear();
@@ -11,12 +11,12 @@ import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.GCMParameterSpec; import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.PBEKeySpec; import javax.crypto.spec.PBEKeySpec;
import javax.crypto.spec.SecretKeySpec; import javax.crypto.spec.SecretKeySpec;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream; import java.io.FileOutputStream;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.attribute.PosixFileAttributeView;
import java.nio.file.attribute.PosixFilePermission;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.security.spec.KeySpec; import java.security.spec.KeySpec;
import java.util.Arrays; import java.util.Arrays;
@@ -24,13 +24,14 @@ import java.util.Base64;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
/** /**
* Armazenamento local seguro e criptografado de PINs/senhas de certificados. * Armazenamento local seguro e criptografado de PINs/senhas de certificados.
* <p> * <p>
* Utiliza criptografia simétrica AES-256-GCM com chave derivada exclusivamente * Utiliza criptografia simétrica AES-256-GCM com chave derivada exclusivamente
* a partir de atributos do usuário e da máquina local (PBKDF2WithHmacSHA256). * a partir de semente aleatória protegida no disco local do usuário e atributos estáveis da máquina (PBKDF2WithHmacSHA256).
* Os dados são gravados com permissões restritas em {@code ~/.cybercore/secure-pins.dat}. * Os dados são gravados com permissões restritas em {@code ~/.cybercore/secure-pins.dat}.
*/ */
public final class SecurePinStorage { public final class SecurePinStorage {
@@ -39,6 +40,7 @@ public final class SecurePinStorage {
private static final String FILE_NAME = "secure-pins.dat"; private static final String FILE_NAME = "secure-pins.dat";
private static final String SALT_FILE_NAME = "secure-pins.salt"; private static final String SALT_FILE_NAME = "secure-pins.salt";
private static final String SEED_FILE_NAME = "secure-pins.seed";
private static final int GCM_IV_LENGTH = 12; private static final int GCM_IV_LENGTH = 12;
private static final int GCM_TAG_LENGTH = 128; private static final int GCM_TAG_LENGTH = 128;
private static final int PBKDF2_ITERATIONS = 65536; private static final int PBKDF2_ITERATIONS = 65536;
@@ -49,6 +51,7 @@ public final class SecurePinStorage {
private final Path storageDirectory; private final Path storageDirectory;
private final Path dataFile; private final Path dataFile;
private final Path saltFile; private final Path saltFile;
private final Path seedFile;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final Map<String, char[]> memoryCache = new ConcurrentHashMap<>(); private final Map<String, char[]> memoryCache = new ConcurrentHashMap<>();
@@ -64,6 +67,7 @@ public final class SecurePinStorage {
this.storageDirectory = Objects.requireNonNull(storageDirectory); this.storageDirectory = Objects.requireNonNull(storageDirectory);
this.dataFile = storageDirectory.resolve(FILE_NAME); this.dataFile = storageDirectory.resolve(FILE_NAME);
this.saltFile = storageDirectory.resolve(SALT_FILE_NAME); this.saltFile = storageDirectory.resolve(SALT_FILE_NAME);
this.seedFile = storageDirectory.resolve(SEED_FILE_NAME);
this.objectMapper = new ObjectMapper(); this.objectMapper = new ObjectMapper();
loadFromDisk(); loadFromDisk();
} }
@@ -200,6 +204,7 @@ public final class SecurePinStorage {
fos.write(iv); fos.write(iv);
fos.write(ciphertext); fos.write(ciphertext);
} }
restrictFilePermissions(dataFile);
} catch (Exception e) { } catch (Exception e) {
logger.error("Erro ao persistir PINs criptografados no disco: {}", e.getMessage(), e); logger.error("Erro ao persistir PINs criptografados no disco: {}", e.getMessage(), e);
} }
@@ -248,22 +253,56 @@ public final class SecurePinStorage {
byte[] salt = new byte[32]; byte[] salt = new byte[32];
new SecureRandom().nextBytes(salt); new SecureRandom().nextBytes(salt);
Files.write(saltFile, salt); Files.write(saltFile, salt);
restrictFilePermissions(saltFile);
return salt; return salt;
} }
private byte[] getOrCreateSeed() throws Exception {
if (Files.exists(seedFile)) {
return Files.readAllBytes(seedFile);
}
byte[] seed = new byte[32];
new SecureRandom().nextBytes(seed);
Files.write(seedFile, seed);
restrictFilePermissions(seedFile);
return seed;
}
private void restrictFilePermissions(Path path) {
try {
PosixFileAttributeView view = Files.getFileAttributeView(path, PosixFileAttributeView.class);
if (view != null) {
Files.setPosixFilePermissions(path, Set.of(
PosixFilePermission.OWNER_READ,
PosixFilePermission.OWNER_WRITE
));
}
} catch (Exception ignored) {
// Em sistemas não-POSIX como Windows, as permissões são herdadas da pasta de perfil do usuário (~/.cybercore)
}
}
private SecretKey deriveKey(byte[] salt) throws Exception { private SecretKey deriveKey(byte[] salt) throws Exception {
// Combina identificadores estáveis exclusivos da máquina e usuário byte[] seed = getOrCreateSeed();
String hostIdentity = String.join(":", String hostIdentity = String.join(":",
System.getProperty("user.name", "unknown"), System.getProperty("user.name", "unknown"),
System.getProperty("user.home", "unknown"), System.getProperty("user.home", "unknown"),
System.getProperty("os.name", "unknown"), System.getProperty("os.name", "unknown"),
System.getProperty("os.arch", "unknown"), System.getProperty("os.arch", "unknown")
"cybercore-signer-hardware-pin-binding"
); );
byte[] hostBytes = hostIdentity.getBytes(StandardCharsets.UTF_8);
byte[] combinedMaterial = new byte[seed.length + hostBytes.length];
System.arraycopy(seed, 0, combinedMaterial, 0, seed.length);
System.arraycopy(hostBytes, 0, combinedMaterial, seed.length, hostBytes.length);
KeySpec spec = new PBEKeySpec(hostIdentity.toCharArray(), salt, PBKDF2_ITERATIONS, KEY_LENGTH_BITS); char[] materialChars = Base64.getEncoder().encodeToString(combinedMaterial).toCharArray();
KeySpec spec = new PBEKeySpec(materialChars, salt, PBKDF2_ITERATIONS, KEY_LENGTH_BITS);
SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256"); SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
byte[] keyBytes = factory.generateSecret(spec).getEncoded(); byte[] keyBytes = factory.generateSecret(spec).getEncoded();
Arrays.fill(materialChars, ' ');
Arrays.fill(combinedMaterial, (byte) 0);
return new SecretKeySpec(keyBytes, "AES"); return new SecretKeySpec(keyBytes, "AES");
} }
} }
@@ -198,6 +198,63 @@ class CcspServerTest {
assertThat(resp.body()).contains("Teste ICP-Brasil"); assertThat(resp.body()).contains("Teste ICP-Brasil");
} }
@Test
@DisplayName("Endpoint /certificates deve rejeitar com 401 requisições sem application_id")
void testGetCertificatesShouldRejectWhenAppIdMissing() throws Exception {
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/ccsp/v1/certificates"))
.header("Origin", "https://meusistema.com.br")
.GET()
.build();
HttpResponse<String> resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
assertThat(resp.statusCode()).isEqualTo(401);
assertThat(resp.body()).contains("APPLICATION_NOT_AUTHORIZED");
}
@Test
@DisplayName("Requisição OPTIONS com origem desconhecida deve ser rejeitada com 403")
void testCorsPreflightShouldRejectUnauthorizedOrigin() throws Exception {
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/ccsp/v1/sign"))
.method("OPTIONS", HttpRequest.BodyPublishers.noBody())
.header("Origin", "https://malicious-attacker.com")
.build();
HttpResponse<Void> resp = httpClient.send(req, HttpResponse.BodyHandlers.discarding());
assertThat(resp.statusCode()).isEqualTo(403);
}
@Test
@DisplayName("Endpoint /sign deve rejeitar requisição sem nonce")
void testSignShouldRejectWhenNonceIsMissing() throws Exception {
byte[] docHash = MessageDigest.getInstance("SHA-256").digest("Documento".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"
}
""".formatted(UUID.randomUUID(), hashBase64);
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(400);
assertThat(resp.body()).contains("REPLAY_DETECTED");
}
@Test @Test
@DisplayName("Requisição OPTIONS deve retornar headers CORS válidos") @DisplayName("Requisição OPTIONS deve retornar headers CORS válidos")
void testCorsPreflight() throws Exception { void testCorsPreflight() throws Exception {
@@ -398,7 +455,7 @@ class CcspServerTest {
assertThat(resp.body()).contains("\"status\":\"success\""); assertThat(resp.body()).contains("\"status\":\"success\"");
assertThat(resp.body()).contains("\"seals\":"); assertThat(resp.body()).contains("\"seals\":");
assertThat(resp.body()).contains("icp-brasil"); assertThat(resp.body()).contains("icp-brasil");
assertThat(resp.body()).contains("gov-br"); assertThat(resp.body()).contains("ICP-Brasil Institucional");
} }
@Test @Test
@@ -58,4 +58,17 @@ class PinSessionManagerTest {
assertThat(manager.hasPin("cert-a")).isFalse(); assertThat(manager.hasPin("cert-a")).isFalse();
assertThat(manager.hasPin("cert-b")).isFalse(); assertThat(manager.hasPin("cert-b")).isFalse();
} }
@Test
@DisplayName("Deve isolar PINs entre diferentes aplicações para o mesmo certificado")
void shouldIsolatePinByApplicationId() {
manager.savePin("app-1", "cert-x", "pin-app-1".toCharArray());
assertThat(manager.hasPin("app-1", "cert-x")).isTrue();
assertThat(manager.getPin("app-1", "cert-x")).isEqualTo("pin-app-1".toCharArray());
// Aplicação 2 não deve ter acesso ao PIN da Aplicação 1
assertThat(manager.hasPin("app-2", "cert-x")).isFalse();
assertThat(manager.getPin("app-2", "cert-x")).isNull();
}
} }
@@ -149,7 +149,7 @@ public class SignaturePromptDialog {
boolean isA3 = cert.type() == CertificateType.A3; boolean isA3 = cert.type() == CertificateType.A3;
boolean isWindowsStore = "provider-win-store".equalsIgnoreCase(cert.providerId()); boolean isWindowsStore = "provider-win-store".equalsIgnoreCase(cert.providerId());
boolean hasStoredPin = isA3 && PinSessionManager.getInstance().hasPin(cert.id()); boolean hasStoredPin = isA3 && PinSessionManager.getInstance().hasPin(context != null ? context.applicationId() : null, cert.id());
if (isA3) { if (isA3) {
pinContainer.getStyleClass().add("dialog-box"); pinContainer.getStyleClass().add("dialog-box");
@@ -255,9 +255,9 @@ public class SignaturePromptDialog {
if (pinProvided) { if (pinProvided) {
char[] pinChars = typedPin.toCharArray(); char[] pinChars = typedPin.toCharArray();
if (cbSavePin.isSelected()) { if (cbSavePin.isSelected()) {
PinSessionManager.getInstance().savePin(cert.id(), pinChars); PinSessionManager.getInstance().savePin(context != null ? context.applicationId() : null, cert.id(), pinChars);
} else { } else {
PinSessionManager.getInstance().clearPin(cert.id()); PinSessionManager.getInstance().clearPin(context != null ? context.applicationId() : null, cert.id());
} }
} }
} }
+1 -1
View File
@@ -15,7 +15,7 @@ cd "${PROJECT_ROOT}"
if [ -n "${JAVA_HOME:-}" ]; then if [ -n "${JAVA_HOME:-}" ]; then
MVN_CMD="${PROJECT_ROOT}/mvnw" MVN_CMD="${PROJECT_ROOT}/mvnw"
else else
export JAVA_HOME="/home/claudecio/.jdks/jdk-25.0.4.1+1" export JAVA_HOME="${JAVA_HOME:-${HOME}/.jdks/jdk-25.0.4.1+1}"
export PATH="${JAVA_HOME}/bin:${PATH}" export PATH="${JAVA_HOME}/bin:${PATH}"
MVN_CMD="${PROJECT_ROOT}/mvnw" MVN_CMD="${PROJECT_ROOT}/mvnw"
fi fi
+27 -7
View File
@@ -4,6 +4,25 @@
* de temas de selos de assinatura digital PAdES (ICP-Brasil). * de temas de selos de assinatura digital PAdES (ICP-Brasil).
*/ */
function escapeSvgText(str) {
if (str === null || str === undefined) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
function sanitizeLogoUrl(url) {
if (!url || typeof url !== 'string') return '';
const trimmed = url.trim();
if (/^data:image\/(png|jpeg|jpg|svg\+xml);base64,[A-Za-z0-9+/=]+$/i.test(trimmed)) {
return trimmed;
}
return '';
}
export class PdfSealDesigner { export class PdfSealDesigner {
/** /**
* @param {Object} options Configurações iniciais do designer. * @param {Object} options Configurações iniciais do designer.
@@ -592,8 +611,9 @@ export class PdfSealDesigner {
} }
let iconGraphic = ''; let iconGraphic = '';
if (t.customLogoBase64) { const safeLogo = sanitizeLogoUrl(t.customLogoBase64);
iconGraphic = `<image xlink:href="${t.customLogoBase64}" href="${t.customLogoBase64}" x="12" y="14" width="34" height="54" preserveAspectRatio="xMidYMid meet" />`; if (safeLogo) {
iconGraphic = `<image xlink:href="${safeLogo}" href="${safeLogo}" x="12" y="14" width="34" height="54" preserveAspectRatio="xMidYMid meet" />`;
} else { } else {
switch (t.iconStyle) { switch (t.iconStyle) {
case 'star': case 'star':
@@ -640,11 +660,11 @@ export class PdfSealDesigner {
</g> </g>
<!-- Tipografia do Selo --> <!-- Tipografia do Selo -->
<text x="54" y="19" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="8.5" font-weight="bold" fill="${t.primaryColor}" letter-spacing="0.3">${t.customHeader || 'DOCUMENTO ASSINADO DIGITALMENTE'}</text> <text x="54" y="19" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="8.5" font-weight="bold" fill="${escapeSvgText(t.primaryColor)}" letter-spacing="0.3">${escapeSvgText(t.customHeader || 'DOCUMENTO ASSINADO DIGITALMENTE')}</text>
<text x="54" y="34" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="12" font-weight="bold" fill="${t.textColor}">MARIA DA SILVA</text> <text x="54" y="34" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="12" font-weight="bold" fill="${escapeSvgText(t.textColor)}">MARIA DA SILVA</text>
<text x="54" y="47" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="8" fill="#475569">${[docText, extraText].filter(Boolean).join(' • ')}</text> <text x="54" y="47" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="8" fill="#475569">${escapeSvgText([docText, extraText].filter(Boolean).join(' • '))}</text>
<text x="54" y="58" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="8" fill="#475569">${dateText ? `Data: ${dateText}` : ''}</text> <text x="54" y="58" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="8" fill="#475569">${escapeSvgText(dateText ? `Data: ${dateText}` : '')}</text>
<text x="54" y="70" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="7.5" fill="#64748b">${t.customFooter || 'ICP-Brasil • MP nº 2.200-2/2001'}</text> <text x="54" y="70" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="7.5" fill="#64748b">${escapeSvgText(t.customFooter || 'ICP-Brasil • MP nº 2.200-2/2001')}</text>
</svg> </svg>
`; `;
+27 -7
View File
@@ -4,6 +4,25 @@
* e geração do payload de assinatura em tempo real. * e geração do payload de assinatura em tempo real.
*/ */
function escapeSvgText(str) {
if (str === null || str === undefined) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
function sanitizeLogoUrl(url) {
if (!url || typeof url !== 'string') return '';
const trimmed = url.trim();
if (/^data:image\/(png|jpeg|jpg|svg\+xml);base64,[A-Za-z0-9+/=]+$/i.test(trimmed)) {
return trimmed;
}
return '';
}
export class PdfStampPicker { export class PdfStampPicker {
/** /**
* @param {Object} options Configurações do componente. * @param {Object} options Configurações do componente.
@@ -949,8 +968,9 @@ export class PdfStampPicker {
else if (name.length > 26) nameFontSize = 10.0; else if (name.length > 26) nameFontSize = 10.0;
let iconGraphic = ''; let iconGraphic = '';
if (theme.customLogoBase64) { const safeLogo = sanitizeLogoUrl(theme.customLogoBase64);
iconGraphic = `<image xlink:href="${theme.customLogoBase64}" href="${theme.customLogoBase64}" x="12" y="14" width="34" height="54" preserveAspectRatio="xMidYMid meet" />`; if (safeLogo) {
iconGraphic = `<image xlink:href="${safeLogo}" href="${safeLogo}" x="12" y="14" width="34" height="54" preserveAspectRatio="xMidYMid meet" />`;
} else { } else {
switch (theme.iconStyle || theme.id) { switch (theme.iconStyle || theme.id) {
case 'star': case 'star':
@@ -1004,11 +1024,11 @@ export class PdfStampPicker {
</g> </g>
<!-- Tipografia do Selo --> <!-- Tipografia do Selo -->
<text x="54" y="19" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="8.5" font-weight="bold" fill="${theme.primaryColor || '#0d47a1'}" letter-spacing="0.3">${header}</text> <text x="54" y="19" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="8.5" font-weight="bold" fill="${escapeSvgText(theme.primaryColor || '#0d47a1')}" letter-spacing="0.3">${escapeSvgText(header)}</text>
<text x="54" y="34" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="${nameFontSize}" font-weight="bold" fill="${theme.textColor || '#0f172a'}">${name}</text> <text x="54" y="34" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="${nameFontSize}" font-weight="bold" fill="${escapeSvgText(theme.textColor || '#0f172a')}">${escapeSvgText(name)}</text>
<text x="54" y="47" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="8" fill="#475569">${doc}</text> <text x="54" y="47" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="8" fill="#475569">${escapeSvgText(doc)}</text>
<text x="54" y="58" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="8" fill="#475569">Data: ${dateText}</text> <text x="54" y="58" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="8" fill="#475569">Data: ${escapeSvgText(dateText)}</text>
<text x="54" y="70" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="7.5" fill="#64748b">${footer}</text> <text x="54" y="70" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="7.5" fill="#64748b">${escapeSvgText(footer)}</text>
</svg> </svg>
`; `;
+2 -1
View File
@@ -136,7 +136,8 @@ export class CyberCoreSigner {
* @returns {Promise<Array<Object>>} Lista de certificados com metadados públicos. * @returns {Promise<Array<Object>>} Lista de certificados com metadados públicos.
*/ */
async getCertificates() { async getCertificates() {
const resp = await this._fetch('/ccsp/v1/certificates', { const url = `/ccsp/v1/certificates?application_id=${encodeURIComponent(this.applicationId)}`;
const resp = await this._fetch(url, {
method: 'GET' method: 'GET'
}); });