fix: enhance security with strict file permissions, pin isolation, and robust protocol validations
This commit is contained in:
+1
-1
@@ -55,7 +55,7 @@ echo -e "${COLOR_RESET}"
|
||||
# 1. Verificação e Auto-Detecção do Java (JDK 21+)
|
||||
if ! command -v java &>/dev/null; then
|
||||
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-21-openjdk-amd64"
|
||||
"/usr/lib/jvm/default-java"
|
||||
|
||||
+62
-19
@@ -218,8 +218,13 @@ public class CcspServer {
|
||||
appId = getQueryParam(uri, "application_id");
|
||||
}
|
||||
|
||||
// Verifica autorização se app_id e origin forem informados
|
||||
if (appId != null && !appId.isBlank()) {
|
||||
// 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);
|
||||
@@ -229,7 +234,6 @@ public class CcspServer {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<ManagedCertificate> certs = certificateManager.getAvailableCertificates();
|
||||
if (certs.isEmpty()) {
|
||||
@@ -350,15 +354,18 @@ public class CcspServer {
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Validação anti-replay (Nonce)
|
||||
if (request.nonce() != null && !request.nonce().isBlank()) {
|
||||
// 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
|
||||
String appId = request.applicationId();
|
||||
@@ -520,14 +527,18 @@ public class CcspServer {
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.nonce() != null && !request.nonce().isBlank()) {
|
||||
// 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();
|
||||
if (appId == null || appId.isBlank()) {
|
||||
@@ -735,14 +746,18 @@ public class CcspServer {
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.nonce() != null && !request.nonce().isBlank()) {
|
||||
// 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();
|
||||
if (appId == null || appId.isBlank()) {
|
||||
@@ -1032,17 +1047,10 @@ 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 = "*";
|
||||
}
|
||||
|
||||
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);
|
||||
exchange.close();
|
||||
@@ -1051,6 +1059,41 @@ public class CcspServer {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 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())) {
|
||||
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()) {
|
||||
|
||||
+78
-28
@@ -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 applicationId, String certId) {
|
||||
if (certId != null) {
|
||||
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) {
|
||||
if (certId != null) {
|
||||
char[] old = sessionPins.remove(certId);
|
||||
if (old != null) {
|
||||
Arrays.fill(old, ' ');
|
||||
}
|
||||
}
|
||||
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();
|
||||
|
||||
+46
-7
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+58
-1
@@ -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
|
||||
|
||||
+13
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ cd "${PROJECT_ROOT}"
|
||||
if [ -n "${JAVA_HOME:-}" ]; then
|
||||
MVN_CMD="${PROJECT_ROOT}/mvnw"
|
||||
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}"
|
||||
MVN_CMD="${PROJECT_ROOT}/mvnw"
|
||||
fi
|
||||
|
||||
@@ -4,6 +4,25 @@
|
||||
* de temas de selos de assinatura digital PAdES (ICP-Brasil).
|
||||
*/
|
||||
|
||||
function escapeSvgText(str) {
|
||||
if (str === null || str === undefined) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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 {
|
||||
/**
|
||||
* @param {Object} options Configurações iniciais do designer.
|
||||
@@ -592,8 +611,9 @@ export class PdfSealDesigner {
|
||||
}
|
||||
|
||||
let iconGraphic = '';
|
||||
if (t.customLogoBase64) {
|
||||
iconGraphic = `<image xlink:href="${t.customLogoBase64}" href="${t.customLogoBase64}" x="12" y="14" width="34" height="54" preserveAspectRatio="xMidYMid meet" />`;
|
||||
const safeLogo = sanitizeLogoUrl(t.customLogoBase64);
|
||||
if (safeLogo) {
|
||||
iconGraphic = `<image xlink:href="${safeLogo}" href="${safeLogo}" x="12" y="14" width="34" height="54" preserveAspectRatio="xMidYMid meet" />`;
|
||||
} else {
|
||||
switch (t.iconStyle) {
|
||||
case 'star':
|
||||
@@ -640,11 +660,11 @@ export class PdfSealDesigner {
|
||||
</g>
|
||||
|
||||
<!-- 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="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="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="58" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="8" fill="#475569">${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="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="${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">${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">${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">${escapeSvgText(t.customFooter || 'ICP-Brasil • MP nº 2.200-2/2001')}</text>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
|
||||
@@ -4,6 +4,25 @@
|
||||
* e geração do payload de assinatura em tempo real.
|
||||
*/
|
||||
|
||||
function escapeSvgText(str) {
|
||||
if (str === null || str === undefined) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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 {
|
||||
/**
|
||||
* @param {Object} options Configurações do componente.
|
||||
@@ -949,8 +968,9 @@ export class PdfStampPicker {
|
||||
else if (name.length > 26) nameFontSize = 10.0;
|
||||
|
||||
let iconGraphic = '';
|
||||
if (theme.customLogoBase64) {
|
||||
iconGraphic = `<image xlink:href="${theme.customLogoBase64}" href="${theme.customLogoBase64}" x="12" y="14" width="34" height="54" preserveAspectRatio="xMidYMid meet" />`;
|
||||
const safeLogo = sanitizeLogoUrl(theme.customLogoBase64);
|
||||
if (safeLogo) {
|
||||
iconGraphic = `<image xlink:href="${safeLogo}" href="${safeLogo}" x="12" y="14" width="34" height="54" preserveAspectRatio="xMidYMid meet" />`;
|
||||
} else {
|
||||
switch (theme.iconStyle || theme.id) {
|
||||
case 'star':
|
||||
@@ -1004,11 +1024,11 @@ export class PdfStampPicker {
|
||||
</g>
|
||||
|
||||
<!-- 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="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="47" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="8" fill="#475569">${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="70" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="7.5" fill="#64748b">${footer}</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="${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">${escapeSvgText(doc)}</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">${escapeSvgText(footer)}</text>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
|
||||
@@ -136,7 +136,8 @@ export class CyberCoreSigner {
|
||||
* @returns {Promise<Array<Object>>} Lista de certificados com metadados públicos.
|
||||
*/
|
||||
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'
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user