Kravchenko

Web Lab

АудитБлогКонтакты

Kravchenko

Web Lab

Разрабатываем сайты и автоматизацию на современных фреймворках под ключ

Услуги
ЛендингМногостраничныйВизитка
E-commerceБронированиеПортфолио
Навигация
БлогКонтактыАудит
Обратная связь
+7 921 567-11-16
info@kravlab.ru
с 09:00 до 18:00

© 2026 Все права защищены

•

ИП Кравченко Никита Владимирович

•

ОГРНИП: 324784700339743

Политика конфиденциальности

Шифрование персональных данных на уровне приложения с поиском: соответствие требованиям без провалов в UX

Разработка и технологии5 марта 2026 г.
Покажу, как шифровать поля в базе так, чтобы сохранить быстрый поиск по e‑mail/телефону и не держать открытые данные. Разберём схему таблиц, индексы, ключи, ротацию и готовые функции на Go для шифрования и «слепых» индексов.
Шифрование персональных данных на уровне приложения с поиском: соответствие требованиям без провалов в UX

Оглавление

  • Зачем шифровать на уровне приложения
  • Что именно шифровать и как не «перешифровать» систему
  • Две задачи: защита данных и поиск без утечек
  • Схема в базе: шифротекст + «слепые» индексы
  • Ключи и их ротация: просто и безопасно
  • Код на Go: шифрование (AES‑GCM) и «слепые» индексы (HMAC)
  • Запросы в PostgreSQL: равенство и поиск по префиксу
  • Производительность и стоимость хранения
  • Пошаговая миграция без простоя
  • Контроль инцидентов и резервные копии
  • Частые ошибки и чек‑лист внедрения
  • Итог

Зачем шифровать на уровне приложения

  • Требования законов и стандартов. Персональные и платёжные данные по 152‑ФЗ/GDPR/PCI не должны лежать в базе в открытом виде.
  • Реальный периметр. Утечки случаются не только из‑за взлома сервера, но и при ошибках в SQL, доступах DBA, тестовых дампах.
  • Контроль рисков. Шифрование на уровне приложения даёт нам: кто бы ни получил дамп — видит бессмысленный набор байтов. Расшифровать может только код с ключом.

При этом бизнесу нужны поиск, валидации и отчёты. Значит, шифруем так, чтобы не потерять UX и не расплющить базу до «холодного архива».

Что именно шифровать и как не «перешифровать» систему

Шифруем поля с высокой чувствительностью:

  • e‑mail, телефон, имя, адрес, паспорт/ИНН
  • комментарии оператора (могут содержать PII)
  • токены сторонних сервисов, секреты

Не шифруем:

  • служебные флаги, статусы, счётчики
  • агрегаты без персональных данных

Важно: не пытайтесь «сортировать» по зашифрованным полям — это почти всегда утечка порядка. Сортировку по таким полям делаем после отбора (в памяти приложения) или используем витрины, где допустима контролируемая утечка (например, первый символ фамилии).

Две задачи: защита данных и поиск без утечек

  • Защита данных: используем современный режим шифрования с проверкой целостности (AEAD), например AES‑GCM. Каждый шифротекст — с уникальным одноразовым числом (nonce).
  • Поиск без утечек: классические индексы по открытому тексту больше нельзя. Решение — «слепые» индексы (blind index): вместо значения храним криптографический отпечаток, рассчитанный на секретном ключе. База видит только отпечаток, но мы можем находить равные значения. Для префиксного поиска — отпечатки n‑грамм (например, всех трёхбуквенных фрагментов).

Важные ограничения и безопасность:

  • Равенство. Слепые индексы по HMAC не раскрывают значение, но раскрывают факт совпадения одинаковых значений между строками (и это нормально для бизнес‑поиска по равенству).
  • Префиксы и n‑граммы. Дают утечки о составе строки (мозаика префиксов). Применяем только там, где это нужно, и с нормализацией данных.

Схема в базе: шифротекст + «слепые» индексы

Пример таблицы пользователей (PostgreSQL):

-- В таблице нет «голых» e‑mail/телефона.
-- Храним шифротекст (bytea) + слепые индексы (bytea/bytea[]),
-- а также версию ключа для дальнейшей ротации.

CREATE TABLE users (
  id                bigserial PRIMARY KEY,
  email_enc         bytea      NOT NULL,
  email_bi          bytea      NOT NULL,  -- HMAC(email_norm)
  email_ngrams_bi   bytea[]    NOT NULL,  -- HMAC для каждого 3‑грамма
  phone_enc         bytea,
  phone_bi          bytea,
  aead_key_ver      smallint   NOT NULL DEFAULT 1,
  bi_key_ver        smallint   NOT NULL DEFAULT 1,
  created_at        timestamptz NOT NULL DEFAULT now()
);

-- Поиск по точному совпадению e‑mail (регистронезависимо)
CREATE UNIQUE INDEX users_email_bi_uq ON users(email_bi);

-- Поиск по префиксу/части e‑mail через n‑граммы
CREATE INDEX users_email_ngrams_gin ON users USING gin (email_ngrams_bi);

-- По номеру телефона (точное совпадение после нормализации)
CREATE INDEX users_phone_bi_idx ON users(phone_bi);

Примечания:

  • bytea[] с GIN позволяет быстро проверять «массив содержит все элементы».
  • Для e‑mail нормализуем: trim, lower, одна кодировка домена; для телефона — формат типа E.164 (цифры + «+»).

Ключи и их ротация: просто и безопасно

Рекомендуем простую модель с разделением ключей:

  • K_aead — ключ для шифрования значений (AES‑GCM)
  • K_bi — ключ для HMAC слепых индексов

Почему раздельно: если кто‑то увидит K_bi, он не получит доступ к шифротекстам и наоборот.

Версионирование ключей:

  • Храним номера версий (aead_key_ver, bi_key_ver) в строке.
  • При ротации добавляем новые ключи с версией N+1.
  • Новые записи пишем на новых ключах, старые постепенно перешифровываем партиями.

Где хранить ключи:

  • Базовый вариант: в переменных окружения контейнера, доступ ограничен секретами оркестратора.
  • Лучше: обёртка (envelope encryption) — мастер‑ключ в KMS/HSM, рабочие ключи зашифрованы и лежат в конфиге. Приложение при старте расшифровывает их через KMS и держит в памяти.

Код на Go: шифрование (AES‑GCM) и «слепые» индексы (HMAC)

Ниже — готовые функции для:

  • шифрования/расшифрования (AES‑GCM, nonce 12 байт)
  • расчёта слепого индекса для равенства (HMAC‑SHA‑256, усечение до 16 байт)
  • расчёта слепых индексов для n‑грамм
  • нормализации e‑mail/телефона/строки
package main

import (
	"crypto/aes"
	"crypto/cipher"
	"crypto/hmac"
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"errors"
	"fmt"
	"regexp"
	"sort"
	"strings"
)

// EncryptAEAD шифрует p с помощью AES‑GCM (K_aead) и доп. данных aad.
// Возвращает бинарный буфер: nonce || ciphertext || tag (как делает GCM).
func EncryptAEAD(kAEAD, p, aad []byte) ([]byte, error) {
	if len(kAEAD) != 16 && len(kAEAD) != 24 && len(kAEAD) != 32 {
		return nil, errors.New("AES key must be 16/24/32 bytes")
	}
	block, err := aes.NewCipher(kAEAD)
	if err != nil {
		return nil, err
	}
	gcm, err := cipher.NewGCM(block)
	if err != nil {
		return nil, err
	}
	nonce := make([]byte, gcm.NonceSize()) // 12 байт по умолчанию
	if _, err := rand.Read(nonce); err != nil {
		return nil, err
	}
	ct := gcm.Seal(nil, nonce, p, aad)
	out := append(nonce, ct...)
	return out, nil
}

// DecryptAEAD расшифровывает буфер (nonce||ciphertext||tag) с тем же aad.
func DecryptAEAD(kAEAD, buf, aad []byte) ([]byte, error) {
	block, err := aes.NewCipher(kAEAD)
	if err != nil {
		return nil, err
	}
	gcm, err := cipher.NewGCM(block)
	if err != nil {
		return nil, err
	}
	if len(buf) < gcm.NonceSize() {
		return nil, errors.New("ciphertext too short")
	}
	nonce := buf[:gcm.NonceSize()]
	ct := buf[gcm.NonceSize():]
	pt, err := gcm.Open(nil, nonce, ct, aad)
	if err != nil {
		return nil, err
	}
	return pt, nil
}

// BlindIndexEq возвращает слепой индекс (HMAC‑SHA‑256) для точного сравнения.
// Усекём до 16 байт — компактнее индекс и достаточно для крайне малого риска коллизий.
func BlindIndexEq(kBI []byte, normalized string) []byte {
	h := hmac.New(sha256.New, kBI)
	h.Write([]byte(normalized))
	d := h.Sum(nil)
	return d[:16]
}

// BlindIndexNGrams возвращает слепые индексы для всех n‑грамм строки.
// Дубликаты удаляются, порядок фиксируем (сортировка) для стабильности.
func BlindIndexNGrams(kBI []byte, normalized string, n int) [][]byte {
	var grams []string
	if n <= 0 {
		n = 3
	}
	// Для строк короче n вернём один грам — всю строку
	if len([]rune(normalized)) <= n {
		if normalized == "" {
			return [][]byte{}
		}
		grams = []string{normalized}
	} else {
		r := []rune(normalized)
		for i := 0; i <= len(r)-n; i++ {
			grams = append(grams, string(r[i:i+n]))
		}
	}
	// Уникализируем
	m := make(map[string]struct{}, len(grams))
	var uniq []string
	for _, g := range grams {
		if _, ok := m[g]; !ok {
			m[g] = struct{}{}
			uniq = append(uniq, g)
		}
	}
	sort.Strings(uniq)
	res := make([][]byte, 0, len(uniq))
	for _, g := range uniq {
		res = append(res, BlindIndexEq(kBI, g))
	}
	return res
}

// Нормализация
var ws = regexp.MustCompile(`\s+`)
var nonDigit = regexp.MustCompile(`[^0-9+]`)

// NormalizeEmail: обрезаем пробелы, приводим к нижнему регистру, схлопываем пробелы внутри.
// Дополнительно можно нормализовать домен до punycode — опущено для краткости.
func NormalizeEmail(s string) string {
	s = strings.TrimSpace(s)
	s = strings.ToLower(s)
	s = ws.ReplaceAllString(s, "")
	return s
}

// NormalizePhone: оставляем + и цифры, убираем прочее.
func NormalizePhone(s string) string {
	s = strings.TrimSpace(s)
	s = nonDigit.ReplaceAllString(s, "")
	// Можно добавить правила страны, ведущие нули, и т.п.
	return s
}

// NormalizeText: общая нормализация — trim + lower + схлопывание пробелов.
func NormalizeText(s string) string {
	s = strings.TrimSpace(s)
	s = strings.ToLower(s)
	s = ws.ReplaceAllString(s, " ")
	return s
}

// Утилиты кодирования
func B64(b []byte) string { return base64.StdEncoding.EncodeToString(b) }
func Hex(b []byte) string { return hex.EncodeToString(b) }

func main() {
	// Для примера ключи зашиты в код — так делать нельзя в проде.
	// Возьмите их из секретов окружения/менеджера секретов.
	kAEAD := []byte("0123456789abcdef0123456789abcdef") // 32 байта
	kBI := []byte("abcdef0123456789abcdef0123456789")   // 32 байта

	email := "  ALICE.Example+promo@Example.ORG  "
	nEmail := NormalizeEmail(email)

	// Готовим слепые индексы
	biEmail := BlindIndexEq(kBI, nEmail)
	biEmailNgrams := BlindIndexNGrams(kBI, nEmail, 3)

	// Шифруем значение (aad — контекст: имя таблицы и поля)
	aad := []byte("users.email")
	ct, err := EncryptAEAD(kAEAD, []byte(nEmail), aad)
	if err != nil {
		panic(err)
	}

	// Печатаем как мы бы писали в базу
	fmt.Println("email_enc (base64):", B64(ct))
	fmt.Println("email_bi  (hex):   ", Hex(biEmail))
	fmt.Printf("email_ngrams_bi (hex): [")
	for i, g := range biEmailNgrams {
		if i > 0 { fmt.Print(", ") }
		fmt.Print(Hex(g))
	}
	fmt.Println("]")

	// Проверка расшифрования
	pt, err := DecryptAEAD(kAEAD, ct, aad)
	if err != nil { panic(err) }
	fmt.Println("decrypted:", string(pt))
}

Что важно в коде:

  • Разные ключи для AEAD и HMAC.
  • Нормализация значения перед расчётом индексов и шифрованием — иначе не найдёте то, что ищете.
  • Усечение HMAC снижает размер индекса. 16 байт (128 бит) — хороший баланс для низкого риска коллизий и компактных индексов.

Запросы в PostgreSQL: равенство и поиск по префиксу

Вставка (пример с псевдоданными):

INSERT INTO users (email_enc, email_bi, email_ngrams_bi, aead_key_ver, bi_key_ver)
VALUES (
  decode('BASE64_CIPHERTEXT', 'base64'),
  decode('001122aabbccddeeff001122aabbccdd', 'hex'),
  ARRAY[ decode('aa01...', 'hex'), decode('bb02...', 'hex') ]::bytea[],
  2, 2
);

Поиск по точному e‑mail (регистронезависимо):

-- На приложении: normalized = NormalizeEmail(query)
-- На приложении: h = HMAC(normalized)
SELECT id, email_enc
FROM users
WHERE email_bi = decode($1, 'hex')
LIMIT 1;

Префиксный поиск (например, начинающиеся на "ali"):

-- На приложении: grams = NGrams(NormalizeEmail("ali"), 3)
-- На приложении: hashed = HMAC(gram) для каждого
SELECT id
FROM users
WHERE email_ngrams_bi @> ARRAY[
  decode($1,'hex'),
  decode($2,'hex'),
  decode($3,'hex')
]::bytea[]
LIMIT 50;

Сортировка по e‑mail в чистом виде недоступна без раскрытия порядка. Практические варианты:

  • Сначала отфильтровать по индексу, затем расшифровать значения выбранных строк в приложении и отсортировать в памяти (подходит при небольших выдачах: 100–1000 элементов).
  • Добавить контролируемую витрину для сортировки (например, первый символ или группа по алфавиту), понимая риск утечки категории, а не полного значения.

Производительность и стоимость хранения

  • Хранение. Шифротекст увеличивается на длину nonce и тега (GCM даёт +28 байт обычно). Слепой индекс равенства — 16 байт. N‑граммы — от длины строки: для e‑mail из 20 символов будет ~18 трёхграмм.
  • Индексы. btree по 16‑байтовому HMAC компактный и быстрый. GIN по bytea[] масштабируется линейно от количества грамм.
  • CPU. AES‑GCM и HMAC быстры и аппаратно ускоряются на современных CPU. Чаще всего узкое место — не CPU, а лишние round‑trips и медленные запросы без индексов.

Практика: на пике в 5–10 тыс. запросов/сек на шифрование/HMAC средний 8‑ядерный сервер не будет упираться в CPU при аккуратной реализации и пуле соединений к БД.

Пошаговая миграция без простоя

  1. Добавить колонки и индексы:
  • email_enc bytea NOT NULL
  • email_bi bytea NOT NULL
  • email_ngrams_bi bytea[] NOT NULL
  • версии ключей
  1. Двойная запись в коде: при создании/изменении пользователя
  • записываем и «голые» колонки (временно), и зашифрованные
  1. Фоновое наполнение (backfill) партиями по id с LIMIT/OFFSET или по ключу пагинации:
  • читать «голые» значения
  • вычислять шифротекст и индексы
  • обновлять строку
  1. Переключить чтения на зашифрованные поля:
  • поиск — по слепым индексам
  • значение — расшифровать из email_enc
  1. Удалить открытые колонки и их индексы, выключить двойную запись.

  2. Включить контроль: алерты на попытки запроса к удалённым колонкам (в логах приложения/SQL‑прокси).

Контроль инцидентов и резервные копии

  • Бэкапы. Шифрование на уровне приложения совместимо с любыми бэкапами БД — в дампах всё зашифровано. Но хранить ключи отдельно от бэкапов — обязательно.
  • Наблюдаемость. Счётчики успешных/ошибочных расшифровок, попытки поиска с пустыми индексами (ошибки нормализации), скорость бэкфилла, доля строк на старых ключах.
  • Тест восстановления. Регулярно поднимайте стенд «из бэкапа + из копии ключей», проверяйте расшифровку и поиск.

Частые ошибки и чек‑лист внедрения

Ошибки:

  • Хранить ключ и данные рядом (в той же БД) — нельзя.
  • Использовать один ключ и для AEAD, и для HMAC — нельзя (разделяйте).
  • Не нормализовать строку перед индексом — поиск «ломается».
  • Пытаться сортировать по шифротексту или использовать «сортирующее» шифрование — утечка порядка.
  • Слишком короткие индексы (например, 8 байт) — растёт риск коллизий.

Чек‑лист:

  • Выбрана стратегия: AEAD для значения, HMAC для индекса(ов).
  • Определены нормы нормализации для каждого поля (e‑mail, телефон, ФИО).
  • Реализовано разделение ключей и их версии, план ротации.
  • Добавлены индексы: btree для равенства, GIN для n‑грамм, если нужен префиксный поиск.
  • Продуман backfill и двойная запись на время миграции.
  • Настроены бэкапы и проверка восстановления с ключами.
  • Мониторинг ошибок расшифрования и метрик поиска.

Итог

Шифрование на уровне приложения не убивает поиск и UX, если опереться на две простые идеи: хранить сами значения в зашифрованном виде (AEAD) и искать по «слепым» индексам из HMAC. Такая схема снижает риск утечек из дампов и админских доступов, при этом остаётся понятной для разработчиков и поддержки. Добавьте версионирование ключей и аккуратную миграцию — и у вас будет надёжная защита персональных данных без тяжёлой инфраструктуры и отдельных сервисов.


PostgreSQLбезопасностьшифрование