
При этом бизнесу нужны поиск, валидации и отчёты. Значит, шифруем так, чтобы не потерять UX и не расплющить базу до «холодного архива».
Шифруем поля с высокой чувствительностью:
Не шифруем:
Важно: не пытайтесь «сортировать» по зашифрованным полям — это почти всегда утечка порядка. Сортировку по таким полям делаем после отбора (в памяти приложения) или используем витрины, где допустима контролируемая утечка (например, первый символ фамилии).
Важные ограничения и безопасность:
Пример таблицы пользователей (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);
Примечания:
Рекомендуем простую модель с разделением ключей:
Почему раздельно: если кто‑то увидит K_bi, он не получит доступ к шифротекстам и наоборот.
Версионирование ключей:
Где хранить ключи:
Ниже — готовые функции для:
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))
}
Что важно в коде:
Вставка (пример с псевдоданными):
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 в чистом виде недоступна без раскрытия порядка. Практические варианты:
Практика: на пике в 5–10 тыс. запросов/сек на шифрование/HMAC средний 8‑ядерный сервер не будет упираться в CPU при аккуратной реализации и пуле соединений к БД.
Удалить открытые колонки и их индексы, выключить двойную запись.
Включить контроль: алерты на попытки запроса к удалённым колонкам (в логах приложения/SQL‑прокси).
Ошибки:
Чек‑лист:
Шифрование на уровне приложения не убивает поиск и UX, если опереться на две простые идеи: хранить сами значения в зашифрованном виде (AEAD) и искать по «слепым» индексам из HMAC. Такая схема снижает риск утечек из дампов и админских доступов, при этом остаётся понятной для разработчиков и поддержки. Добавьте версионирование ключей и аккуратную миграцию — и у вас будет надёжная защита персональных данных без тяжёлой инфраструктуры и отдельных сервисов.