Шифрование персональных данных в хранилище (AES-GCM)

This commit is contained in:
Onbehalfofme
2026-09-22 21:42:14 +03:00
committed by dakocha3
parent 6afe3442f2
commit 1c9c030283
8 changed files with 215 additions and 17 deletions
@@ -0,0 +1,87 @@
package ru.pdguard.core;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.HexFormat;
/**
* Шифрование исходных персональных данных в хранилище.
*
* <p>ПДН не должны лежать в памяти и в общем слое в открытом виде: даже если
* процесс или Redis скомпрометированы, исходные значения остаются недоступными
* без ключа. Используется AES-GCM — аутентифицированное шифрование, которое
* защищает и от подмены шифротекста.
*
* <p>Ключ задаётся настройкой {@code pdguard.store.encryption-key} (32 байта в
* hex). Пока ключ не задан, шифрование выключено — это нужно для тестов и для
* сборки, где хранилище не содержит чувствительных данных.
*/
@Component
public class PayloadCipher {
private static final String ALGORITHM = "AES";
private static final String TRANSFORMATION = "AES/GCM/NoPadding";
private static final int GCM_TAG_BITS = 128;
private static final int IV_BYTES = 12;
private final SecretKeySpec key;
private final SecureRandom random = new SecureRandom();
public PayloadCipher(@Value("${pdguard.store.encryption-key:}") String hexKey) {
this.key = hexKey == null || hexKey.isBlank() ? null : new SecretKeySpec(HexFormat.of().parseHex(hexKey), ALGORITHM);
}
/** Выключенное шифрование — для тестов и сборки без ключа. */
public static PayloadCipher disabled() {
return new PayloadCipher("");
}
public boolean enabled() {
return key != null;
}
/** Шифрует текст; при выключенном шифровании возвращает исходный текст. */
public String encrypt(String plaintext) {
if (key == null) {
return plaintext;
}
try {
byte[] iv = new byte[IV_BYTES];
random.nextBytes(iv);
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_BITS, iv));
byte[] encrypted = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
byte[] combined = new byte[iv.length + encrypted.length];
System.arraycopy(iv, 0, combined, 0, iv.length);
System.arraycopy(encrypted, 0, combined, iv.length, encrypted.length);
return Base64.getEncoder().encodeToString(combined);
} catch (Exception e) {
throw new IllegalStateException("Не удалось зашифровать персональные данные", e);
}
}
/** Дешифрует текст; при выключенном шифровании возвращает исходный текст. */
public String decrypt(String ciphertext) {
if (key == null) {
return ciphertext;
}
try {
byte[] combined = Base64.getDecoder().decode(ciphertext);
byte[] iv = new byte[IV_BYTES];
System.arraycopy(combined, 0, iv, 0, iv.length);
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_BITS, iv));
byte[] decrypted = cipher.doFinal(combined, iv.length, combined.length - iv.length);
return new String(decrypted, StandardCharsets.UTF_8);
} catch (Exception e) {
throw new IllegalStateException("Не удалось расшифровать персональные данные", e);
}
}
}
@@ -64,25 +64,29 @@ public class PayloadStore {
private final long maxChars;
private final long ttlMillis;
private final SharedIndex shared;
private final PayloadCipher cipher;
@Autowired
public PayloadStore(
@Value("${pdguard.store.max-chars:134217728}") long maxChars,
@Value("${pdguard.store.ttl-minutes:30}") int ttlMinutes,
SharedIndex shared) {
SharedIndex shared,
PayloadCipher cipher) {
this.maxChars = maxChars;
this.ttlMillis = ttlMinutes * 60_000L;
this.shared = shared;
this.cipher = cipher;
}
/** Конструктор для тестов: только локальная память, общий слой выключен. */
/** Конструктор для тестов: только локальная память, общий слой и шифрование выключены. */
public PayloadStore(long maxChars, int ttlMinutes) {
this(maxChars, ttlMinutes, SharedIndex.disabled());
this(maxChars, ttlMinutes, SharedIndex.disabled(), PayloadCipher.disabled());
}
public void put(String system, String payloadId, String original, String masked) {
long now = System.currentTimeMillis();
Entry entry = new Entry(system, original, masked, fingerprint(masked), now + ttlMillis);
String encrypted = cipher.encrypt(original);
Entry entry = new Entry(system, encrypted, masked, fingerprint(masked), now + ttlMillis);
String idKey = scoped(system, payloadId);
Entry replaced = byId.put(idKey, entry);
@@ -93,14 +97,14 @@ public class PayloadStore {
sweepExpired(now);
evictWhileOverLimit();
shared.put(system, payloadId, original, masked, entry.fingerprint());
shared.put(system, payloadId, encrypted, masked, entry.fingerprint());
}
public Entry byId(String system, String payloadId) {
String idKey = scoped(system, payloadId);
Entry entry = byId.get(idKey);
if (entry != null && entry.alive(System.currentTimeMillis())) {
return entry;
return decrypt(entry);
}
if (entry != null) {
forget(idKey, entry);
@@ -112,7 +116,7 @@ public class PayloadStore {
// Соседний узел уже выполнял прямой шаг: забираем соответствие к себе,
// чтобы повторное обращение обошлось без сети.
put(system, payloadId, fromShared.original(), fromShared.masked());
return byId.get(idKey);
return decrypt(byId.get(idKey));
}
/**
@@ -124,7 +128,7 @@ public class PayloadStore {
String fingerprint = fingerprint(masked);
Entry entry = byMaskFingerprint.get(scoped(system, fingerprint));
if (entry != null && entry.alive(System.currentTimeMillis())) {
return entry.original();
return cipher.decrypt(entry.original());
}
return shared.originalForFingerprint(system, fingerprint);
}
@@ -186,6 +190,15 @@ public class PayloadStore {
}
}
/** Возвращает запись с расшифрованным исходным текстом. */
private Entry decrypt(Entry entry) {
if (entry == null) {
return null;
}
return new Entry(entry.system(), cipher.decrypt(entry.original()), entry.masked(),
entry.fingerprint(), entry.expiresAt());
}
private static String fingerprint(String value) {
try {
MessageDigest sha = MessageDigest.getInstance("SHA-256");
+13 -8
View File
@@ -49,6 +49,7 @@ public class SharedIndex {
private final Duration ttl;
private final StringRedisTemplate redis;
private final ObjectMapper mapper;
private final PayloadCipher cipher;
private final AtomicInteger consecutiveFailures = new AtomicInteger();
private volatile long silentUntil;
@@ -57,31 +58,34 @@ public class SharedIndex {
public SharedIndex(StringRedisTemplate redis,
@Value("${pdguard.store.backend:memory}") String backend,
@Value("${pdguard.store.ttl-minutes:30}") int ttlMinutes,
ObjectMapper mapper) {
ObjectMapper mapper,
PayloadCipher cipher) {
this.redis = redis;
this.enabled = "redis".equalsIgnoreCase(backend);
this.ttl = Duration.ofMinutes(ttlMinutes);
this.mapper = mapper;
this.cipher = cipher;
}
/** Выключенный слой — для тестов и для сборки без Redis. */
public static SharedIndex disabled() {
return new SharedIndex(null, "memory", 30, new ObjectMapper());
return new SharedIndex(null, "memory", 30, new ObjectMapper(), PayloadCipher.disabled());
}
public boolean enabled() {
return enabled;
}
public void put(String system, String payloadId, String original, String masked,
public void put(String system, String payloadId, String original, String masked,
String maskFingerprint) {
if (unavailable()) {
return;
}
try {
String encrypted = cipher.encrypt(original);
redis.opsForValue().set(KEY_BY_ID + scoped(system, payloadId),
toJson(new SharedEntry(original, masked)), ttl);
redis.opsForValue().set(KEY_BY_MASK + scoped(system, maskFingerprint), original, ttl);
toJson(new SharedEntry(encrypted, masked)), ttl);
redis.opsForValue().set(KEY_BY_MASK + scoped(system, maskFingerprint), encrypted, ttl);
noteSuccess();
} catch (RuntimeException e) {
noteFailure("записать", e);
@@ -95,7 +99,8 @@ public class SharedIndex {
try {
String json = redis.opsForValue().get(KEY_BY_ID + scoped(system, payloadId));
noteSuccess();
return json == null ? null : fromJson(json);
SharedEntry entry = json == null ? null : fromJson(json);
return entry == null ? null : new SharedEntry(cipher.decrypt(entry.original()), entry.masked());
} catch (RuntimeException e) {
noteFailure("прочитать", e);
return null;
@@ -107,9 +112,9 @@ public class SharedIndex {
return null;
}
try {
String original = redis.opsForValue().get(KEY_BY_MASK + scoped(system, maskFingerprint));
String encrypted = redis.opsForValue().get(KEY_BY_MASK + scoped(system, maskFingerprint));
noteSuccess();
return original;
return encrypted == null ? null : cipher.decrypt(encrypted);
} catch (RuntimeException e) {
noteFailure("прочитать", e);
return null;
+2
View File
@@ -39,6 +39,8 @@ pdguard:
backend: memory
max-chars: 134217728
ttl-minutes: 30
# 32 байта в hex; AES-256 ключ шифрования хранилища
encryption-key: "46a38b200c6df557a5fd2c8a57ad3fec6b710b9f3e1fef1451d121a094f63573"
min-concurrent: 8
max-concurrent: 2000
target-latency-ms: 200
@@ -0,0 +1,18 @@
package ru.pdguard;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import ru.pdguard.core.PayloadCipher;
import static org.junit.jupiter.api.Assertions.assertTrue;
@SpringBootTest
class CipherEnabledTest {
@Autowired PayloadCipher cipher;
@Test
void cipherIsEnabled() {
assertTrue(cipher.enabled(), "шифрование должно быть включено ключом из конфигурации");
}
}
@@ -0,0 +1,22 @@
package ru.pdguard;
import org.junit.jupiter.api.Test;
import ru.pdguard.core.PayloadCipher;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** Проверка ключа шифрования из application.yml. */
class CipherKeyTest {
private static final String KEY = "46a38b200c6df557a5fd2c8a57ad3fec6b710b9f3e1fef1451d121a094f63573";
@Test
void keyIsValidAes256() {
PayloadCipher cipher = new PayloadCipher(KEY);
assertTrue(cipher.enabled(), "ключ должен включать шифрование");
String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456";
assertEquals(original, cipher.decrypt(cipher.encrypt(original)),
"round-trip с ключом из application.yml должен работать");
}
}
@@ -0,0 +1,50 @@
package ru.pdguard;
import org.junit.jupiter.api.Test;
import ru.pdguard.core.PayloadCipher;
import ru.pdguard.core.PayloadStore;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
/** Шифрование персональных данных в хранилище. */
class PayloadCipherTest {
/** 32 байта в hex — валидный AES-256 ключ. */
private static final String KEY = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
@Test
void encryptDecryptRoundTrip() {
PayloadCipher cipher = new PayloadCipher(KEY);
String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456";
String encrypted = cipher.encrypt(original);
assertNotEquals(original, encrypted, "шифротекст не должен совпадать с исходником");
assertEquals(original, cipher.decrypt(encrypted), "должно расшифроваться обратно");
}
@Test
void disabledCipherPassesThrough() {
PayloadCipher cipher = PayloadCipher.disabled();
String original = "Клиент Иванов";
assertEquals(original, cipher.encrypt(original), "без ключа шифрование выключено");
assertEquals(original, cipher.decrypt(original), "без ключа дешифрование выключено");
}
@Test
void storeStoresEncryptedButReturnsPlaintext() {
PayloadCipher cipher = new PayloadCipher(KEY);
PayloadStore store = new PayloadStore(1_000_000L, 30, ru.pdguard.core.SharedIndex.disabled(), cipher);
String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456";
String masked = "Клиент И. И. И., паспорт 45** ****56";
store.put("test", "id-1", original, masked);
// Чтение по id возвращает исходный текст.
PayloadStore.Entry entry = store.byId("test", "id-1");
assertEquals(original, entry.original(), "чтение по id должно вернуть исходный текст");
// Чтение по маске возвращает исходный текст.
assertEquals(original, store.originalForMask("test", masked),
"чтение по маске должно вернуть исходный текст");
}
}
+1
View File
@@ -5,6 +5,7 @@ pdguard:
engine: off
store:
backend: memory
encryption-key: "46a38b200c6df557a5fd2c8a57ad3fec6b710b9f3e1fef1451d121a094f63573"
spring:
data: