diff --git a/src/main/java/ru/pdguard/core/PayloadCipher.java b/src/main/java/ru/pdguard/core/PayloadCipher.java new file mode 100644 index 0000000..8f8ed1f --- /dev/null +++ b/src/main/java/ru/pdguard/core/PayloadCipher.java @@ -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; + +/** + * Шифрование исходных персональных данных в хранилище. + * + *
ПДН не должны лежать в памяти и в общем слое в открытом виде: даже если + * процесс или Redis скомпрометированы, исходные значения остаются недоступными + * без ключа. Используется AES-GCM — аутентифицированное шифрование, которое + * защищает и от подмены шифротекста. + * + *
Ключ задаётся настройкой {@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); + } + } +} \ No newline at end of file diff --git a/src/main/java/ru/pdguard/core/PayloadStore.java b/src/main/java/ru/pdguard/core/PayloadStore.java index 56756d3..514e49e 100644 --- a/src/main/java/ru/pdguard/core/PayloadStore.java +++ b/src/main/java/ru/pdguard/core/PayloadStore.java @@ -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"); diff --git a/src/main/java/ru/pdguard/core/SharedIndex.java b/src/main/java/ru/pdguard/core/SharedIndex.java index 750572c..939a3a7 100644 --- a/src/main/java/ru/pdguard/core/SharedIndex.java +++ b/src/main/java/ru/pdguard/core/SharedIndex.java @@ -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, - String maskFingerprint) { +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; diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 3fed6e3..d896b84 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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 diff --git a/src/test/java/ru/pdguard/CipherEnabledTest.java b/src/test/java/ru/pdguard/CipherEnabledTest.java new file mode 100644 index 0000000..609c172 --- /dev/null +++ b/src/test/java/ru/pdguard/CipherEnabledTest.java @@ -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(), "шифрование должно быть включено ключом из конфигурации"); + } +} diff --git a/src/test/java/ru/pdguard/CipherKeyTest.java b/src/test/java/ru/pdguard/CipherKeyTest.java new file mode 100644 index 0000000..f74ba27 --- /dev/null +++ b/src/test/java/ru/pdguard/CipherKeyTest.java @@ -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 должен работать"); + } +} diff --git a/src/test/java/ru/pdguard/PayloadCipherTest.java b/src/test/java/ru/pdguard/PayloadCipherTest.java new file mode 100644 index 0000000..014e5e6 --- /dev/null +++ b/src/test/java/ru/pdguard/PayloadCipherTest.java @@ -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), + "чтение по маске должно вернуть исходный текст"); + } +} \ No newline at end of file diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index b83958a..c10846f 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -5,6 +5,7 @@ pdguard: engine: off store: backend: memory + encryption-key: "46a38b200c6df557a5fd2c8a57ad3fec6b710b9f3e1fef1451d121a094f63573" spring: data: