Шифрование персональных данных в хранилище (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 maxChars;
|
||||||
private final long ttlMillis;
|
private final long ttlMillis;
|
||||||
private final SharedIndex shared;
|
private final SharedIndex shared;
|
||||||
|
private final PayloadCipher cipher;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
public PayloadStore(
|
public PayloadStore(
|
||||||
@Value("${pdguard.store.max-chars:134217728}") long maxChars,
|
@Value("${pdguard.store.max-chars:134217728}") long maxChars,
|
||||||
@Value("${pdguard.store.ttl-minutes:30}") int ttlMinutes,
|
@Value("${pdguard.store.ttl-minutes:30}") int ttlMinutes,
|
||||||
SharedIndex shared) {
|
SharedIndex shared,
|
||||||
|
PayloadCipher cipher) {
|
||||||
this.maxChars = maxChars;
|
this.maxChars = maxChars;
|
||||||
this.ttlMillis = ttlMinutes * 60_000L;
|
this.ttlMillis = ttlMinutes * 60_000L;
|
||||||
this.shared = shared;
|
this.shared = shared;
|
||||||
|
this.cipher = cipher;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Конструктор для тестов: только локальная память, общий слой выключен. */
|
/** Конструктор для тестов: только локальная память, общий слой и шифрование выключены. */
|
||||||
public PayloadStore(long maxChars, int ttlMinutes) {
|
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) {
|
public void put(String system, String payloadId, String original, String masked) {
|
||||||
long now = System.currentTimeMillis();
|
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);
|
String idKey = scoped(system, payloadId);
|
||||||
|
|
||||||
Entry replaced = byId.put(idKey, entry);
|
Entry replaced = byId.put(idKey, entry);
|
||||||
@@ -93,14 +97,14 @@ public class PayloadStore {
|
|||||||
sweepExpired(now);
|
sweepExpired(now);
|
||||||
evictWhileOverLimit();
|
evictWhileOverLimit();
|
||||||
|
|
||||||
shared.put(system, payloadId, original, masked, entry.fingerprint());
|
shared.put(system, payloadId, encrypted, masked, entry.fingerprint());
|
||||||
}
|
}
|
||||||
|
|
||||||
public Entry byId(String system, String payloadId) {
|
public Entry byId(String system, String payloadId) {
|
||||||
String idKey = scoped(system, payloadId);
|
String idKey = scoped(system, payloadId);
|
||||||
Entry entry = byId.get(idKey);
|
Entry entry = byId.get(idKey);
|
||||||
if (entry != null && entry.alive(System.currentTimeMillis())) {
|
if (entry != null && entry.alive(System.currentTimeMillis())) {
|
||||||
return entry;
|
return decrypt(entry);
|
||||||
}
|
}
|
||||||
if (entry != null) {
|
if (entry != null) {
|
||||||
forget(idKey, entry);
|
forget(idKey, entry);
|
||||||
@@ -112,7 +116,7 @@ public class PayloadStore {
|
|||||||
// Соседний узел уже выполнял прямой шаг: забираем соответствие к себе,
|
// Соседний узел уже выполнял прямой шаг: забираем соответствие к себе,
|
||||||
// чтобы повторное обращение обошлось без сети.
|
// чтобы повторное обращение обошлось без сети.
|
||||||
put(system, payloadId, fromShared.original(), fromShared.masked());
|
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);
|
String fingerprint = fingerprint(masked);
|
||||||
Entry entry = byMaskFingerprint.get(scoped(system, fingerprint));
|
Entry entry = byMaskFingerprint.get(scoped(system, fingerprint));
|
||||||
if (entry != null && entry.alive(System.currentTimeMillis())) {
|
if (entry != null && entry.alive(System.currentTimeMillis())) {
|
||||||
return entry.original();
|
return cipher.decrypt(entry.original());
|
||||||
}
|
}
|
||||||
return shared.originalForFingerprint(system, fingerprint);
|
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) {
|
private static String fingerprint(String value) {
|
||||||
try {
|
try {
|
||||||
MessageDigest sha = MessageDigest.getInstance("SHA-256");
|
MessageDigest sha = MessageDigest.getInstance("SHA-256");
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ public class SharedIndex {
|
|||||||
private final Duration ttl;
|
private final Duration ttl;
|
||||||
private final StringRedisTemplate redis;
|
private final StringRedisTemplate redis;
|
||||||
private final ObjectMapper mapper;
|
private final ObjectMapper mapper;
|
||||||
|
private final PayloadCipher cipher;
|
||||||
|
|
||||||
private final AtomicInteger consecutiveFailures = new AtomicInteger();
|
private final AtomicInteger consecutiveFailures = new AtomicInteger();
|
||||||
private volatile long silentUntil;
|
private volatile long silentUntil;
|
||||||
@@ -57,31 +58,34 @@ public class SharedIndex {
|
|||||||
public SharedIndex(StringRedisTemplate redis,
|
public SharedIndex(StringRedisTemplate redis,
|
||||||
@Value("${pdguard.store.backend:memory}") String backend,
|
@Value("${pdguard.store.backend:memory}") String backend,
|
||||||
@Value("${pdguard.store.ttl-minutes:30}") int ttlMinutes,
|
@Value("${pdguard.store.ttl-minutes:30}") int ttlMinutes,
|
||||||
ObjectMapper mapper) {
|
ObjectMapper mapper,
|
||||||
|
PayloadCipher cipher) {
|
||||||
this.redis = redis;
|
this.redis = redis;
|
||||||
this.enabled = "redis".equalsIgnoreCase(backend);
|
this.enabled = "redis".equalsIgnoreCase(backend);
|
||||||
this.ttl = Duration.ofMinutes(ttlMinutes);
|
this.ttl = Duration.ofMinutes(ttlMinutes);
|
||||||
this.mapper = mapper;
|
this.mapper = mapper;
|
||||||
|
this.cipher = cipher;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Выключенный слой — для тестов и для сборки без Redis. */
|
/** Выключенный слой — для тестов и для сборки без Redis. */
|
||||||
public static SharedIndex disabled() {
|
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() {
|
public boolean enabled() {
|
||||||
return 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) {
|
String maskFingerprint) {
|
||||||
if (unavailable()) {
|
if (unavailable()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
String encrypted = cipher.encrypt(original);
|
||||||
redis.opsForValue().set(KEY_BY_ID + scoped(system, payloadId),
|
redis.opsForValue().set(KEY_BY_ID + scoped(system, payloadId),
|
||||||
toJson(new SharedEntry(original, masked)), ttl);
|
toJson(new SharedEntry(encrypted, masked)), ttl);
|
||||||
redis.opsForValue().set(KEY_BY_MASK + scoped(system, maskFingerprint), original, ttl);
|
redis.opsForValue().set(KEY_BY_MASK + scoped(system, maskFingerprint), encrypted, ttl);
|
||||||
noteSuccess();
|
noteSuccess();
|
||||||
} catch (RuntimeException e) {
|
} catch (RuntimeException e) {
|
||||||
noteFailure("записать", e);
|
noteFailure("записать", e);
|
||||||
@@ -95,7 +99,8 @@ public class SharedIndex {
|
|||||||
try {
|
try {
|
||||||
String json = redis.opsForValue().get(KEY_BY_ID + scoped(system, payloadId));
|
String json = redis.opsForValue().get(KEY_BY_ID + scoped(system, payloadId));
|
||||||
noteSuccess();
|
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) {
|
} catch (RuntimeException e) {
|
||||||
noteFailure("прочитать", e);
|
noteFailure("прочитать", e);
|
||||||
return null;
|
return null;
|
||||||
@@ -107,9 +112,9 @@ public class SharedIndex {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
String original = redis.opsForValue().get(KEY_BY_MASK + scoped(system, maskFingerprint));
|
String encrypted = redis.opsForValue().get(KEY_BY_MASK + scoped(system, maskFingerprint));
|
||||||
noteSuccess();
|
noteSuccess();
|
||||||
return original;
|
return encrypted == null ? null : cipher.decrypt(encrypted);
|
||||||
} catch (RuntimeException e) {
|
} catch (RuntimeException e) {
|
||||||
noteFailure("прочитать", e);
|
noteFailure("прочитать", e);
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ pdguard:
|
|||||||
backend: memory
|
backend: memory
|
||||||
max-chars: 134217728
|
max-chars: 134217728
|
||||||
ttl-minutes: 30
|
ttl-minutes: 30
|
||||||
|
# 32 байта в hex; AES-256 ключ шифрования хранилища
|
||||||
|
encryption-key: "46a38b200c6df557a5fd2c8a57ad3fec6b710b9f3e1fef1451d121a094f63573"
|
||||||
min-concurrent: 8
|
min-concurrent: 8
|
||||||
max-concurrent: 2000
|
max-concurrent: 2000
|
||||||
target-latency-ms: 200
|
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
|
engine: off
|
||||||
store:
|
store:
|
||||||
backend: memory
|
backend: memory
|
||||||
|
encryption-key: "46a38b200c6df557a5fd2c8a57ad3fec6b710b9f3e1fef1451d121a094f63573"
|
||||||
|
|
||||||
spring:
|
spring:
|
||||||
data:
|
data:
|
||||||
|
|||||||
Reference in New Issue
Block a user