feat(security): protect authorized-apps.json integrity with hmac and restricted permissions

This commit is contained in:
Claudecio Martins
2026-10-20 14:50:34 -03:00
parent 4e6dd2a029
commit eeeecb0604
4 changed files with 296 additions and 8 deletions
@@ -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());
}
@@ -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());
}
}
}
}
@@ -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();
}
}
@@ -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();
}
}