feat(security): protect authorized-apps.json integrity with hmac and restricted permissions
This commit is contained in:
+60
-8
@@ -25,10 +25,12 @@ public final class ApplicationRegistry {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(ApplicationRegistry.class);
|
||||
private static final String DEFAULT_FILE_NAME = "authorized-apps.json";
|
||||
private static final String HMAC_FILE_SUFFIX = ".hmac";
|
||||
|
||||
private static final ApplicationRegistry INSTANCE = new ApplicationRegistry();
|
||||
|
||||
private final Path storagePath;
|
||||
private final HmacIntegrityManager integrityManager;
|
||||
private final Map<String, AuthorizedApplication> applications = new ConcurrentHashMap<>();
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@@ -37,15 +39,27 @@ public final class ApplicationRegistry {
|
||||
}
|
||||
|
||||
public ApplicationRegistry() {
|
||||
this(resolveDefaultStoragePath());
|
||||
this(resolveDefaultStoragePath(), new HmacIntegrityManager());
|
||||
}
|
||||
|
||||
public ApplicationRegistry(Path storagePath) {
|
||||
this(storagePath, new HmacIntegrityManager(resolveKeyPathForStorage(storagePath)));
|
||||
}
|
||||
|
||||
public ApplicationRegistry(Path storagePath, HmacIntegrityManager integrityManager) {
|
||||
this.storagePath = storagePath;
|
||||
this.integrityManager = integrityManager != null ? integrityManager : new HmacIntegrityManager();
|
||||
this.objectMapper = new ObjectMapper().registerModule(new JavaTimeModule());
|
||||
load();
|
||||
}
|
||||
|
||||
private static Path resolveKeyPathForStorage(Path storagePath) {
|
||||
if (storagePath == null || storagePath.getParent() == null) {
|
||||
return null;
|
||||
}
|
||||
return storagePath.getParent().resolve(".vault_key");
|
||||
}
|
||||
|
||||
private static Path resolveDefaultStoragePath() {
|
||||
String userHome = System.getProperty("user.home", ".");
|
||||
return Path.of(userHome, ".cybercore", DEFAULT_FILE_NAME);
|
||||
@@ -108,8 +122,15 @@ public final class ApplicationRegistry {
|
||||
return Collections.unmodifiableList(new ArrayList<>(applications.values()));
|
||||
}
|
||||
|
||||
private Path resolveHmacPath() {
|
||||
if (storagePath == null) {
|
||||
return null;
|
||||
}
|
||||
return Path.of(storagePath.toString() + HMAC_FILE_SUFFIX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Carrega as aplicações persistidas em disco.
|
||||
* Carrega as aplicações persistidas em disco verificando a integridade por HMAC-SHA256.
|
||||
*/
|
||||
public synchronized void load() {
|
||||
if (storagePath == null || !Files.exists(storagePath)) {
|
||||
@@ -118,22 +139,42 @@ public final class ApplicationRegistry {
|
||||
}
|
||||
|
||||
try {
|
||||
byte[] fileBytes = Files.readAllBytes(storagePath);
|
||||
Path hmacPath = resolveHmacPath();
|
||||
|
||||
if (hmacPath != null && Files.exists(hmacPath)) {
|
||||
String expectedHmac = Files.readString(hmacPath).trim();
|
||||
boolean valid = integrityManager.verifyHmac(fileBytes, expectedHmac);
|
||||
if (!valid) {
|
||||
logger.error("ALERTA DE AUDITORIA: Adulteração detectada no arquivo de aplicações autorizadas {}. O HMAC diverge do esperado. Todas as autorizações foram invalidadas.", storagePath);
|
||||
applications.clear();
|
||||
return;
|
||||
}
|
||||
} else if (hmacPath != null) {
|
||||
// Se existe o JSON mas não o HMAC correspondente (migração/inicialização segura)
|
||||
logger.warn("ALERTA DE AUDITORIA: Arquivo {} sem assinatura HMAC de integridade. Calculando e gerando assinatura inicial.", storagePath);
|
||||
String newHmac = integrityManager.computeHmacHex(fileBytes);
|
||||
Files.writeString(hmacPath, newHmac);
|
||||
HmacIntegrityManager.restrictPermissions(hmacPath);
|
||||
}
|
||||
|
||||
List<AuthorizedApplication> list = objectMapper.readValue(
|
||||
storagePath.toFile(),
|
||||
fileBytes,
|
||||
new TypeReference<List<AuthorizedApplication>>() {}
|
||||
);
|
||||
applications.clear();
|
||||
for (AuthorizedApplication app : list) {
|
||||
applications.put(app.applicationId(), app);
|
||||
}
|
||||
logger.info("Carregadas {} aplicação(ões) autorizada(s) de {}", applications.size(), storagePath);
|
||||
logger.info("Carregadas {} aplicação(ões) autorizada(s) íntegras de {}", applications.size(), storagePath);
|
||||
} catch (Exception e) {
|
||||
logger.warn("Falha ao ler aplicações autorizadas de {}: {}", storagePath, e.getMessage());
|
||||
applications.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Salva as aplicações atuais em disco.
|
||||
* Salva as aplicações atuais em disco com assinatura HMAC e permissões de acesso restritas.
|
||||
*/
|
||||
public synchronized void save() {
|
||||
if (storagePath == null) {
|
||||
@@ -144,12 +185,23 @@ public final class ApplicationRegistry {
|
||||
Path parent = storagePath.getParent();
|
||||
if (parent != null && !Files.exists(parent)) {
|
||||
Files.createDirectories(parent);
|
||||
HmacIntegrityManager.restrictPermissions(parent);
|
||||
}
|
||||
objectMapper.writerWithDefaultPrettyPrinter().writeValue(
|
||||
storagePath.toFile(),
|
||||
|
||||
byte[] jsonBytes = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(
|
||||
new ArrayList<>(applications.values())
|
||||
);
|
||||
logger.debug("Aplicações autorizadas salvas com sucesso em {}", storagePath);
|
||||
Files.write(storagePath, jsonBytes);
|
||||
HmacIntegrityManager.restrictPermissions(storagePath);
|
||||
|
||||
Path hmacPath = resolveHmacPath();
|
||||
if (hmacPath != null) {
|
||||
String hmacHex = integrityManager.computeHmacHex(jsonBytes);
|
||||
Files.writeString(hmacPath, hmacHex);
|
||||
HmacIntegrityManager.restrictPermissions(hmacPath);
|
||||
}
|
||||
|
||||
logger.debug("Aplicações autorizadas e HMAC salvos com sucesso em {}", storagePath);
|
||||
} catch (Exception e) {
|
||||
logger.error("Erro ao salvar aplicações autorizadas em {}: {}", storagePath, e.getMessage());
|
||||
}
|
||||
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
package br.com.cybercore.signer.core.security;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.FileSystems;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.PosixFilePermission;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.EnumSet;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Gerenciador de integridade criptográfica por HMAC-SHA256 para arquivos de configuração e
|
||||
* autorizações locais, com custódia de chave mestre isolada e permissões de arquivo restritas.
|
||||
*/
|
||||
public class HmacIntegrityManager {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(HmacIntegrityManager.class);
|
||||
private static final String HMAC_ALGORITHM = "HmacSHA256";
|
||||
private static final int KEY_SIZE_BYTES = 32;
|
||||
private static final String DEFAULT_KEY_FILE = ".vault_key";
|
||||
|
||||
private final Path keyPath;
|
||||
private byte[] secretKey;
|
||||
|
||||
public HmacIntegrityManager() {
|
||||
this(resolveDefaultKeyPath());
|
||||
}
|
||||
|
||||
public HmacIntegrityManager(Path keyPath) {
|
||||
this.keyPath = keyPath;
|
||||
initSecretKey();
|
||||
}
|
||||
|
||||
private static Path resolveDefaultKeyPath() {
|
||||
String userHome = System.getProperty("user.home", ".");
|
||||
return Path.of(userHome, ".cybercore", DEFAULT_KEY_FILE);
|
||||
}
|
||||
|
||||
private synchronized void initSecretKey() {
|
||||
if (keyPath == null) {
|
||||
byte[] ephemeralKey = new byte[KEY_SIZE_BYTES];
|
||||
new SecureRandom().nextBytes(ephemeralKey);
|
||||
this.secretKey = ephemeralKey;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (Files.exists(keyPath)) {
|
||||
byte[] loaded = Files.readAllBytes(keyPath);
|
||||
if (loaded.length >= KEY_SIZE_BYTES) {
|
||||
this.secretKey = loaded;
|
||||
restrictPermissions(keyPath);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Gera nova chave criptográfica forte
|
||||
byte[] newKey = new byte[KEY_SIZE_BYTES];
|
||||
new SecureRandom().nextBytes(newKey);
|
||||
this.secretKey = newKey;
|
||||
|
||||
Path parent = keyPath.getParent();
|
||||
if (parent != null && !Files.exists(parent)) {
|
||||
Files.createDirectories(parent);
|
||||
restrictPermissions(parent);
|
||||
}
|
||||
|
||||
Files.write(keyPath, newKey);
|
||||
restrictPermissions(keyPath);
|
||||
logger.info("Chave mestre de integridade inicializada com permissões restritas em {}", keyPath);
|
||||
} catch (Exception e) {
|
||||
logger.warn("Falha ao persistir chave em {}. Usando chave em memória: {}", keyPath, e.getMessage());
|
||||
byte[] fallbackKey = new byte[KEY_SIZE_BYTES];
|
||||
new SecureRandom().nextBytes(fallbackKey);
|
||||
this.secretKey = fallbackKey;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcula o HMAC-SHA256 dos dados informados e devolve em hexadecimal.
|
||||
*/
|
||||
public String computeHmacHex(byte[] data) {
|
||||
if (data == null) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
Mac mac = Mac.getInstance(HMAC_ALGORITHM);
|
||||
SecretKeySpec keySpec = new SecretKeySpec(secretKey, HMAC_ALGORITHM);
|
||||
mac.init(keySpec);
|
||||
byte[] hmacBytes = mac.doFinal(data);
|
||||
return HexFormat.of().formatHex(hmacBytes);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Erro ao calcular HMAC-SHA256: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica a integridade dos dados contra um HMAC esperado usando comparação em tempo constante
|
||||
* para mitigar timing attacks.
|
||||
*/
|
||||
public boolean verifyHmac(byte[] data, String expectedHex) {
|
||||
if (data == null || expectedHex == null || expectedHex.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
String calculatedHex = computeHmacHex(data);
|
||||
byte[] expectedBytes = expectedHex.trim().getBytes(StandardCharsets.UTF_8);
|
||||
byte[] calculatedBytes = calculatedHex.getBytes(StandardCharsets.UTF_8);
|
||||
return MessageDigest.isEqual(expectedBytes, calculatedBytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restringe as permissões do arquivo/diretório para acesso exclusivo do usuário atual (0600 / 0700).
|
||||
*/
|
||||
public static void restrictPermissions(Path path) {
|
||||
if (path == null || !Files.exists(path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
File file = path.toFile();
|
||||
// Proteção padrão Java SE (funciona em Windows e Unix)
|
||||
file.setReadable(false, false);
|
||||
file.setReadable(true, true);
|
||||
file.setWritable(false, false);
|
||||
file.setWritable(true, true);
|
||||
file.setExecutable(false, false);
|
||||
|
||||
// Permissões POSIX estritas (Linux / macOS)
|
||||
if (FileSystems.getDefault().supportedFileAttributeViews().contains("posix")) {
|
||||
try {
|
||||
boolean isDir = Files.isDirectory(path);
|
||||
Set<PosixFilePermission> permissions = isDir
|
||||
? EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE, PosixFilePermission.OWNER_EXECUTE)
|
||||
: EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE);
|
||||
Files.setPosixFilePermissions(path, permissions);
|
||||
} catch (IOException e) {
|
||||
logger.debug("Não foi possível aplicar atributos POSIX em {}: {}", path, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
@@ -44,4 +44,29 @@ class ApplicationRegistryTest {
|
||||
reloaded.revoke("app-123");
|
||||
assertThat(reloaded.findById("app-123")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Deve descartar aplicações e alertar se o arquivo json for adulterado sem HMAC correspondente")
|
||||
void testTamperDetection(@TempDir Path tempDir) throws Exception {
|
||||
Path jsonPath = tempDir.resolve("authorized-apps.json");
|
||||
ApplicationRegistry registry = new ApplicationRegistry(jsonPath);
|
||||
|
||||
AuthorizedApplication app = new AuthorizedApplication(
|
||||
"app-1",
|
||||
"Legit App",
|
||||
Set.of("https://legit.com"),
|
||||
true,
|
||||
Instant.now()
|
||||
);
|
||||
registry.register(app);
|
||||
|
||||
// Adultera o arquivo JSON diretamente no disco simulando malware local
|
||||
java.nio.file.Files.writeString(jsonPath, "[{\"applicationId\":\"malicious\",\"name\":\"Malware\",\"allowedOrigins\":[\"*\"],\"active\":true}]");
|
||||
|
||||
// Novo registry tenta carregar
|
||||
ApplicationRegistry tamperedRegistry = new ApplicationRegistry(jsonPath);
|
||||
assertThat(tamperedRegistry.findById("malicious")).isEmpty();
|
||||
assertThat(tamperedRegistry.findById("app-1")).isEmpty();
|
||||
assertThat(tamperedRegistry.listAll()).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package br.com.cybercore.signer.core.security;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@DisplayName("Testes de Integridade Criptográfica (HmacIntegrityManager)")
|
||||
class HmacIntegrityManagerTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("Deve gerar HMAC-SHA256 válido e verificar correspondência em tempo constante")
|
||||
void testComputeAndVerifyHmac(@TempDir Path tempDir) {
|
||||
Path keyPath = tempDir.resolve(".vault_key");
|
||||
HmacIntegrityManager manager = new HmacIntegrityManager(keyPath);
|
||||
|
||||
byte[] payload = "{\"test\":\"cybercore\"}".getBytes(StandardCharsets.UTF_8);
|
||||
String hmac = manager.computeHmacHex(payload);
|
||||
|
||||
assertThat(hmac).isNotEmpty().hasSize(64); // 256 bits = 64 hex chars
|
||||
assertThat(manager.verifyHmac(payload, hmac)).isTrue();
|
||||
assertThat(manager.verifyHmac("tampered".getBytes(StandardCharsets.UTF_8), hmac)).isFalse();
|
||||
assertThat(manager.verifyHmac(payload, "invalidhmac")).isFalse();
|
||||
assertThat(manager.verifyHmac(null, hmac)).isFalse();
|
||||
assertThat(manager.verifyHmac(payload, null)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Deve reutilizar chave persistida em disco")
|
||||
void testKeyPersistence(@TempDir Path tempDir) {
|
||||
Path keyPath = tempDir.resolve(".vault_key");
|
||||
HmacIntegrityManager manager1 = new HmacIntegrityManager(keyPath);
|
||||
|
||||
byte[] payload = "data-to-protect".getBytes(StandardCharsets.UTF_8);
|
||||
String hmac1 = manager1.computeHmacHex(payload);
|
||||
|
||||
// Novo manager apontando para a mesma chave persistida
|
||||
HmacIntegrityManager manager2 = new HmacIntegrityManager(keyPath);
|
||||
String hmac2 = manager2.computeHmacHex(payload);
|
||||
|
||||
assertThat(hmac1).isEqualTo(hmac2);
|
||||
assertThat(manager2.verifyHmac(payload, hmac1)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Deve restringir permissões de arquivo e diretório")
|
||||
void testRestrictPermissions(@TempDir Path tempDir) throws Exception {
|
||||
Path testFile = tempDir.resolve("protected.txt");
|
||||
Files.writeString(testFile, "sensitive-data");
|
||||
|
||||
HmacIntegrityManager.restrictPermissions(testFile);
|
||||
assertThat(Files.isReadable(testFile)).isTrue();
|
||||
assertThat(Files.isWritable(testFile)).isTrue();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user