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
@@ -218,16 +218,20 @@ public class CcspServer {
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;
}
// Exige obrigatoriamente a identificação da aplicação para listagem de certificados
if (appId == null || appId.isBlank()) {
sendErrorResponse(exchange, 401, CcspErrorCode.APPLICATION_NOT_AUTHORIZED.name(),
"application_id (via query param ou cabeçalho X-CyberCore-AppId) é obrigatório.");
return;
}
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;
}
}
@@ -350,14 +354,17 @@ public class CcspServer {
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;
}
// 3. Validação anti-replay (Nonce obrigatório)
if (request.nonce() == null || request.nonce().isBlank()) {
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.REPLAY_DETECTED,
"O parâmetro 'nonce' é obrigatório para proteção contra replay attack.");
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
@@ -520,13 +527,17 @@ public class CcspServer {
return;
}
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;
}
// Validação anti-replay (Nonce obrigatório)
if (request.nonce() == null || request.nonce().isBlank()) {
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.REPLAY_DETECTED,
"O parâmetro 'nonce' é obrigatório para proteção contra replay attack.");
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();
@@ -735,13 +746,17 @@ public class CcspServer {
return;
}
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;
}
// Validação anti-replay (Nonce obrigatório)
if (request.nonce() == null || request.nonce().isBlank()) {
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.REPLAY_DETECTED,
"O parâmetro 'nonce' é obrigatório para proteção contra replay attack.");
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();
@@ -1032,25 +1047,53 @@ public class CcspServer {
}
private boolean handleCors(HttpExchange exchange) throws IOException {
String path = exchange.getRequestURI().getPath();
String origin = getOrigin(exchange);
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);
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");
// Determina se a origem ou rota é elegível para CORS
boolean isHandshakePath = path.endsWith("/status") || path.endsWith("/authorize");
boolean isAuthorizedOrigin = applicationRegistry.findByOrigin(origin).isPresent() || isLocalOrigin(origin);
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())) {
exchange.sendResponseHeaders(204, -1);
if (isHandshakePath || isAuthorizedOrigin) {
exchange.sendResponseHeaders(204, -1);
} else {
exchange.sendResponseHeaders(403, -1);
}
exchange.close();
return true;
}
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) {
String origin = exchange.getRequestHeaders().getFirst("Origin");
if (origin == null || origin.isBlank()) {
@@ -3,6 +3,8 @@ package br.com.cybercore.signer.core.security;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Arrays;
import java.util.Map;
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.
* 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".
* 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.
*/
public class PinSessionManager {
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 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;
public PinSessionManager() {
@@ -30,63 +36,107 @@ public class PinSessionManager {
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");
String key = buildKey(applicationId, certId);
if (pin == null || pin.length == 0) {
clearPin(certId);
clearPin(applicationId, 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);
clearPin(applicationId, 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);
}
/**
* 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) {
return null;
}
char[] stored = sessionPins.get(certId);
return stored != null ? stored.clone() : null;
String key = buildKey(applicationId, certId);
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) {
if (certId == null || alwaysPromptPin) {
return false;
}
return sessionPins.containsKey(certId);
return hasPin("default", 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) {
char[] old = sessionPins.remove(certId);
if (old != null) {
Arrays.fill(old, ' ');
String key = buildKey(applicationId, certId);
PinEntry old = sessionPins.remove(key);
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.
*/
public synchronized void clearAll() {
for (char[] pin : sessionPins.values()) {
if (pin != null) {
Arrays.fill(pin, ' ');
for (PinEntry entry : sessionPins.values()) {
if (entry != null && entry.pin() != null) {
Arrays.fill(entry.pin(), ' ');
}
}
sessionPins.clear();
@@ -11,12 +11,12 @@ 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.nio.file.attribute.PosixFileAttributeView;
import java.nio.file.attribute.PosixFilePermission;
import java.security.SecureRandom;
import java.security.spec.KeySpec;
import java.util.Arrays;
@@ -24,13 +24,14 @@ import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
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).
* 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}.
*/
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 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_TAG_LENGTH = 128;
private static final int PBKDF2_ITERATIONS = 65536;
@@ -49,6 +51,7 @@ public final class SecurePinStorage {
private final Path storageDirectory;
private final Path dataFile;
private final Path saltFile;
private final Path seedFile;
private final ObjectMapper objectMapper;
private final Map<String, char[]> memoryCache = new ConcurrentHashMap<>();
@@ -64,6 +67,7 @@ public final class SecurePinStorage {
this.storageDirectory = Objects.requireNonNull(storageDirectory);
this.dataFile = storageDirectory.resolve(FILE_NAME);
this.saltFile = storageDirectory.resolve(SALT_FILE_NAME);
this.seedFile = storageDirectory.resolve(SEED_FILE_NAME);
this.objectMapper = new ObjectMapper();
loadFromDisk();
}
@@ -200,6 +204,7 @@ public final class SecurePinStorage {
fos.write(iv);
fos.write(ciphertext);
}
restrictFilePermissions(dataFile);
} catch (Exception 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];
new SecureRandom().nextBytes(salt);
Files.write(saltFile, salt);
restrictFilePermissions(saltFile);
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 {
// Combina identificadores estáveis exclusivos da máquina e usuário
byte[] seed = getOrCreateSeed();
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"
System.getProperty("os.arch", "unknown")
);
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");
byte[] keyBytes = factory.generateSecret(spec).getEncoded();
Arrays.fill(materialChars, ' ');
Arrays.fill(combinedMaterial, (byte) 0);
return new SecretKeySpec(keyBytes, "AES");
}
}
@@ -198,6 +198,63 @@ class CcspServerTest {
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
@DisplayName("Requisição OPTIONS deve retornar headers CORS válidos")
void testCorsPreflight() throws Exception {
@@ -398,7 +455,7 @@ class CcspServerTest {
assertThat(resp.body()).contains("\"status\":\"success\"");
assertThat(resp.body()).contains("\"seals\":");
assertThat(resp.body()).contains("icp-brasil");
assertThat(resp.body()).contains("gov-br");
assertThat(resp.body()).contains("ICP-Brasil Institucional");
}
@Test
@@ -58,4 +58,17 @@ class PinSessionManagerTest {
assertThat(manager.hasPin("cert-a")).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 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) {
pinContainer.getStyleClass().add("dialog-box");
@@ -255,9 +255,9 @@ public class SignaturePromptDialog {
if (pinProvided) {
char[] pinChars = typedPin.toCharArray();
if (cbSavePin.isSelected()) {
PinSessionManager.getInstance().savePin(cert.id(), pinChars);
PinSessionManager.getInstance().savePin(context != null ? context.applicationId() : null, cert.id(), pinChars);
} else {
PinSessionManager.getInstance().clearPin(cert.id());
PinSessionManager.getInstance().clearPin(context != null ? context.applicationId() : null, cert.id());
}
}
}