Шифрование персональных данных в хранилище (AES-GCM)
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
"чтение по маске должно вернуть исходный текст");
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ pdguard:
|
||||
engine: off
|
||||
store:
|
||||
backend: memory
|
||||
encryption-key: "46a38b200c6df557a5fd2c8a57ad3fec6b710b9f3e1fef1451d121a094f63573"
|
||||
|
||||
spring:
|
||||
data:
|
||||
|
||||
Reference in New Issue
Block a user