Шифрование прикладных данных в .NET — от основ к key chain, ротации и компромиссам поиска

от автора

Если вы когда-нибудь выкатывали фичу, которая хранит персональные данные — почтовые адреса, заметки в свободной форме, API-токены, идентификационные номера — у вас наверняка возникала та же неприятная мысль: врядли стоит доверять базе данных. Бэкапы копируются на ноутбуки. Снапшоты оседают на файловых ресурсах. Галочка “encryption at rest” в облачной консоли защищает только от одного конкретного вида кражи — от того, что кто-то унесет диск.

Зашифровать данные до того, как они попадут в базу, на слайде звучит просто — AES, ключ, готово. В реальном коде простая версия ломается ровно в тот момент, когда вы пытаетесь ротировать ключ, найти запись по email, или объяснить ревьюверу, каким именно ключом зашифрована конкретная строка.

В этой статье я разбираю варианты решений для пошагового шифрования отдельных свойств в .NET: семейства алгоритмов и почему промышленные системы их комбинируют, что на самом деле защищает вектор инициализации, почему вам нужен keychain, а не один ключ, и неприятное меню вариантов для поиска по зашифрованным данным. Я буду использовать собственную библиотеку EfCore.EncryptedProperties в качестве сквозного конкретного примера. Главное здесь — концепции, библиотека лишь один из способов их выразить.

1. Что мы на самом деле имеем в виду под “шифрованием документов”

Этот термин покрывает несколько разных вещей. Чтобы честно очертить рамки, вот о чем эта статья и о чем она не идет:

  • В рамках темы. Шифрование отдельных значений — колонки, свойства, JSON-поля — до того, как они попадут в базу. Ключ хранится у приложения (напрямую или через KMS), а база видит только шифротекст.

  • Вне рамок. Полнодисковое шифрование (BitLocker, LUKS, Storage Service Encryption). TLS, который защищает байты в передаче, а не на хранении. End-to-end шифрование сообщений, где сам сервер приложения не получает доступ к открытому тексту. MSSQL “Always encrypted” с ключами в базе данных

Модель угроз — самая обычная. Представьте, что с вашей базой случилось худшее — украденный бэкап, скопированный снепшот — и у атакующего есть каждая строка, которую вы когда-либо записывали. В идеале он видит непрозрачные блобы, бесполезные без ключа, которым управляет ваше приложение.

От чего такое шифрование не защищает — не менее важно. Если атакующий скомпрометирует процесс приложения, он сможет расшифровать все, что может расшифровать процесс — точка. SQL-инъекция, возвращающая строку, вернет ровно то, что приложение расшифровало бы при обычном запросе. Дамп памяти захватит открытый текст сразу после расшифровки. Зашифрованные свойства — это забор вокруг хранилища данных, а не вокруг приложения.

В общем наш маршрут следующий: семейства алгоритмов > режимы и IV > конвертное шифрование > связки ключей > ротация > местоположение ключей > поиск по зашифрованным данным > проработанный пример.

2. Два семейства алгоритмов — и почему нужны оба

Криптография конфиденциальности четко делится на два семейства.

Симметричное шифрование использует один секретный ключ и для шифрования, и для расшифровки. AES — доминирующий представитель. Он быстрый — у современных процессоров есть выделенные AES-инструкции, и можно шифровать гигабайты в секунду на одно ядро — и он работает с данными произвольной длины. Подвох операционный: всем, кому нужно шифровать или расшифровывать, нужен ключ, поэтому доставка ключа этим сторонам безопасным способом — ваша проблема.

Асимметричное шифрование использует пару ключей: открытый ключ шифрует, закрытый расшифровывает. RSA — учебниковый пример. Распределение ключей решается по построению — открытый ключ можно публиковать где угодно — но два неудобных свойства делают асимметричную криптографию плохим выбором для “зашифровать документ”:

  1. Это медленно. На два-три порядка медленнее AES для одного и того же объема данных.

  2. Размер открытого текста ограничен размером ключа. RSA-OAEP с 2048-битным ключом упирается примерно в 190 байт на операцию. Полезно для ключа, бесполезно для записи.

Стандартный промышленный ответ — использовать оба, в схеме под названием конвертное шифрование (envelope encryption): симметричный алгоритм делает основную работу, а асимметричный (или KMS) защищает симметричный ключ. Раздел 4 разбирает это подробно. Суть в том, что “AES vs RSA” — ложный выбор: вам почти всегда нужен AES-зашифрованный payload и RSA-обернутый ключ, в стабильном конверте, который можно сохранить и распарсить.

EfCore.EncryptedProperties использует AES-256-GCM для payload и RSA-OAEP-SHA-256 для обертки симметричных ключей. Оба семейства на месте, и каждое занимается тем, что оно умеет лучше всего.

3. AES — режим и IV не являются деталями реализации

Сказать “мы шифруем AES-ом” — значит почти ничего не сказать. AES — блочный шифр, сам по себе он превращает 16 байт открытого текста в 16 байт шифротекста. Все остальное — как шифровать 17 байт, как обрабатывать следующие 16, дают ли два одинаковых открытых текста одинаковый шифротекст — решается режимом. Безопасность живет именно в режиме.

Практический обзор:

  • ECB (Electronic Codebook). Каждый 16-байтный блок шифруется независимо. Одинаковые блоки открытого текста дают одинаковые блоки шифротекста — именно поэтому знаменитая картинка “ECB-зашифрованного пингвина” все еще узнаваема. Не используйте ECB.

  • CBC (Cipher Block Chaining). Каждый блок XOR-ится с предыдущим блоком шифротекста перед шифрованием, начальный шаг задается вектором инициализации (IV). Избегает утечки ECB, но не обеспечивает целостности — перевернутый бит шифротекста становится перевернутым битом открытого текста дальше по цепочке. Нужен отдельный MAC, и его конструкцию люди постоянно делают неправильно.

  • CTR (Counter). Превращает блочный шифр в поточный, шифруя последовательные значения счетчика и XOR-я их с открытым текстом. Быстро, но снова без целостности.

  • GCM (Galois/Counter Mode). Режим CTR плюс тег аутентификации, вычисленный во время шифрования. Получатель проверяет тег до возврата открытого текста, подмена обнаруживается. Это то, что вам нужно.

GCM принимает три входа помимо ключа и открытого текста: IV (канонически 12 байт), связанные данные (AAD — аутентифицируемые, но не шифруемые, полезны для привязки шифротекста к контексту), и выдает 16-байтный тег аутентификации рядом с шифротекстом.

Самое важное правило про IV в GCM: никогда не переиспользуйте IV с одним и тем же ключом. Не “будет слабее”, а катастрофически сломано. Два сообщения, зашифрованные одной парой (ключ, IV), сливают XOR своих открытых текстов, а ключ аутентификации можно восстановить, что позволяет атакующему подделывать сообщения как угодно. Это правило похоронило не одну промышленную систему.

Есть три разумных способа этого не допустить:

  1. Случайный на каждое шифрование. Генерировать свежий 96-битный IV с помощью CSPRNG каждый раз. При 2⁹⁶ возможных значений коллизии исчезающе маловероятны, пока вы не зашифруете порядка 2⁴⁸ сообщений одним ключом.

  2. Детерминированный счетчик. Поддерживать монотонный счетчик в области одного ключа. Работает хорошо, если можно гарантировать, что параллельные шифрующие стороны не потеряют его, на горизонтально масштабируемом сервисе это сложнее, чем звучит.

  3. Синтетический / SIV. Выводить IV детерминированно из ключа + AAD + открытого текста (AES-SIV, AES-GCM-SIV). Меняет немного производительности на устойчивость к неправильному использованию IV.

EfCore.EncryptedProperties использует вариант 1 — стандартное решение по умолчанию:

// src/EfCore.EncryptedProperties/Cryptography/AesGcmEncryptor.csprivate const int IvSizeBytes = 12;private const int TagSizeBytes = 16;public static (byte[] Ciphertext, byte[] Tag, byte[] Iv) Encrypt(byte[] key, byte[] plaintext, byte[] aad){    var iv = RandomNumberGenerator.GetBytes(IvSizeBytes);    var ciphertext = new byte[plaintext.Length];    var tag = new byte[TagSizeBytes];    using var aesGcm = new AesGcm(key, TagSizeBytes);    aesGcm.Encrypt(iv, plaintext, ciphertext, tag, aad);    return (ciphertext, tag, iv);}

Три детали реализации, на которые стоит указать: 12-байтный IV — стандартный размер для GCM (быстрее, чем 16-байтные, не нужен шаг хеширования), 16-байтный тег — максимум и единственный разумный выбор, если есть место, using на AesGcm важен, потому что тип держит неуправляемый key handle.

Побочный эффект случайных IV на каждое шифрование — шифротекст не детерминирован. Зашифруйте строку alice@example.com дважды одним и тем же ключом — получите два разных блоба. Это ровно то свойство, из-за которого база не может ответить на WHERE Email = 'alice@example.com' — и мы разберем это в разделе 8.

4. Envelope encryption — CEK, KEK и мастер-ключ

До этого момента у нас был один AES-ключ, непрозрачный и якобы магический. Наивное решение положить его в appsettings.json и считать дело сделанным. Два требования быстро ломают этот дизайн: ротация (ключ должен меняться без простоя на повторное шифрование) и хранение ключа (ключ должен жить где-то, куда сложнее дотянуться, чем до конфига приложения).

Ответ — зашифровать данные короткоживущим ключом и хранить этот короткоживущий ключ рядом с шифротекстом, при этом зашифровав его долгоживущим ключом. Эта вложенность и есть Envelope encryption, а промышленная версия использует три уровня:

  • CEK — Content Encryption Key (ключ шифрования контента). Свежий AES-ключ, генерируемый для каждого зашифрованного значения. Он шифрует фактический открытый текст и сразу же оборачивается. CEK одноразовый.

  • KEK — Key Encryption Key (ключ шифрования ключей). Более долгоживущий AES-ключ, оборачивающий CEK. Обычно по одному на назначение (об этом подробнее в разделе 5). Ротируется по календарю.

  • Мастер-ключ. Корень доверия. Живет в укрепленном хранилище — KMS, HSM, Azure Key Vault, в шифрованном pfx/pem файле. Оборачивает KEK. Ротируется редко.

Почему три уровня? Потому что у каждого свой профиль стоимости. CEK не особенно ценны, т.к. шифруют только один документ, дешевы в генерации. KEK ключи слегка более ценны, ротируются по расписанию, должны жить достаточно долго, чтобы расшифровать все, что они когда-либо зашифровали. Мастер-ключи дорого заменять (они могут жить в HSM с журналами аудита и физическим контролем доступа), вызовы к ним могуть быть дорогими, поэтому хочется иметь как можно меньше операций над ними. Структура “обертка-в-обертке” позволяет каждому уровню заниматься своим делом.

De facto wire-формат для конвертно-зашифрованного блоба — JWE (JSON Web Encryption) compact serialization, упаковывающая все в пять сегментов, разделенных точкой и закодированных в base64url:

{header}.{wrapped CEK}.{IV}.{ciphertext}.{auth tag}

EfCore.EncryptedProperties сериализует именно это:

// src/EfCore.EncryptedProperties/Cryptography/JweCompactSerializer.cspublic static string Serialize(JweHeader header, byte[] wrappedCek, byte[] iv, byte[] ciphertext, byte[] tag){    var headerB64 = Base64Url.Encode(Encoding.UTF8.GetBytes(header.ToJson()));    var encKeyB64 = Base64Url.Encode(wrappedCek);    var ivB64 = Base64Url.Encode(iv);    var ciphertextB64 = Base64Url.Encode(ciphertext);    var tagB64 = Base64Url.Encode(tag);    return $"{headerB64}.{encKeyB64}.{ivB64}.{ciphertextB64}.{tagB64}";}

Заголовок — небольшой JSON-объект. Он несет идентификаторы алгоритмов и — что критично — kid (key id), который указывает, какой именно KEK обернул этот CEK:

{  "alg": "A256GCMKW",  "enc": "A256GCM",  "kid": "8f4c1b2e-3a9d-4c2e-9b1a-7c5e3a1d2f4b",  "iv":  "...",  "tag": "..."}

alg описывает алгоритм обертывания ключа (AES-GCM key wrap с 256-битным ключом). enc описывает алгоритм контента (AES-256-GCM). iv и tag внутри заголовка защищают саму операцию обертывания, iv и tag снаружи (сегменты 3 и 5) защищают payload. Две AES-GCM операции, два независимых IV и тега.

Иерархия конверта:

            ┌───────────────────────────┐            │  Мастер-ключ (RSA / HSM)  │            └──────────────┬────────────┘                           │ оборачивает                           ▼            ┌───────────────────────────┐            │   KEK (AES per-purpose)   │            └──────────────┬────────────┘                           │ оборачивает                           ▼            ┌───────────────────────────┐            │   CEK (AES per-value)     │            └──────────────┬────────────┘                           │ шифрует                           ▼                     Открытый текст

Почему это важно для базы: каждое зашифрованное значение несет kid своего KEK в собственном конверте. Приложению никогда не нужно угадывать, каким ключом расшифровывать — шифротекст сам ему говорит. Именно это свойство делает возможным следующий раздел.

5. Связки ключей — когда ключей больше одного

Пора посмотреть на наивный дизайн “один ключ в конфиге” в реальной жизни.

Сценарий: команда безопасности просит вас ротировать ключ шифрования с периодичностью 90 дней. С одним ключом в конфиге у вас два варианта:

  1. Остановить мир, перешифровать каждую строку, выкатить новый ключ. Реально для таблицы в 10 000 строк. Катастрофично для таблицы в 100 миллионов строк.

  2. Отказаться от ротации. Сказать команде безопасности, что хранилище данных не может удовлетворить их политику.

  3. Хранить коллекцию ключей, пытаться расшифровать всеми по очереди =)

Ни то, ни другое, ну и уж точно не третье. Также нет способа из шифротекста ответить на вопрос “каким ключом зашифрована эта строка”, поэтому даже понять, что нужно перешифровать, превращается в гадание. И если ваше приложение хранит и почты клиентов, и медицинские заметки в свободной форме одним и тем же ключом, компрометация этого ключа компрометирует и то, и другое — нет истории про blast radius.

Связка ключей решает все три проблемы. Это версионированная коллекция ключей, разбитая по назначению, с одним сейчас активным для новых записей и всеми старыми, удерживаемыми для чтений:

  • Записи всегда используют сейчас активный ключ для соответствующего назначения.

  • Чтения смотрят на kid в конверте шифротекста и достают именно этот ключ из связки, независимо от того, активный он или нет.

  • Назначения разделяют ключи по классу данных: email, notes, tokens, каждый потенциально со своим независимым расписанием ротации и blast radius.

Конкретно, вот как библиотека моделирует запись KEK:

// src/EfCore.EncryptedProperties/KeyManagement/EncryptedKeyRecord.cspublic sealed class EncryptedKeyRecord{    public required Guid Id { get; init; }    public required string Purpose { get; init; }    public required string RsaKeyId { get; init; }    public required string Algorithm { get; init; }    public required string EncryptedKey { get; init; }    public required DateTimeOffset CreatedAt { get; init; }    public required bool IsActive { get; init; }}

Несколько деталей, на которые стоит обратить внимание. Id — это то, что попадает в каждый конверт шифротекста как kid. Purpose — это домен данных. RsaKeyId фиксирует какая версия мастер-ключа обернула этот KEK — важно, если вы когда-нибудь будете ротировать мастер-ключ, потому что старые KEK по-прежнему указывают на ту версию мастера, что их обернула. EncryptedKey — это сам KEK, уже обернутый мастер-ключом, поэтому строку базы данных безопасно бэкапить.

Менеджер связки ключей принимает решение “какой ключ использовать”:

// src/EfCore.EncryptedProperties/KeyManagement/KeyChainManager.cspublic async ValueTask<KeyMaterial> GetActiveKeyAsync(string purpose, CancellationToken cancellationToken = default){    // ...проверка кеша, блокировки опущены...    var record = await _storage.GetActiveAsync(purpose, cancellationToken);    var shouldRotate = record is not null && ShouldRotate(record);    if (record is not null && !shouldRotate)    {        var decrypted = await DecryptKekAsync(record, cancellationToken);        // кеш + возврат        return decrypted;    }    // ...сгенерировать новый KEK, сохранить, залогировать событие, вернуть...}private bool ShouldRotate(EncryptedKeyRecord record){    if (_options.RotationPolicy.KeyRotateAfter is not { } maxAge)        return false;    return DateTimeOffset.UtcNow - record.CreatedAt > maxAge;}

Две вещи стоит заметить. Во-первых, GetActiveKeyAsync — единственная операция, которая когда-либо создает новый KEK — и делает это только когда нет активной записи или существующая запись прошла возраст ротации. Во-вторых, GetKeyForDecryptAsync(keyId) существует отдельно для пути чтения, который ищет по kid из конверта, а не по назначению. Чтения никогда не запускают ротацию. Записи — да.

Подключение назначений к модели сущности — одна строка на свойство. Библиотека поддерживает и атрибут, и fluent API:

public sealed class Customer{    public Guid Id { get; set; }    [Encrypted("email")]    public string Email { get; set; } = string.Empty;    [Encrypted(KeyPurpose = "notes")]    public EncryptedValue<string> SecretNotes { get; set; } = default!;}

Email и SecretNotes теперь живут на независимых связках ключей. Ротация email ничего не делает с notes и наоборот. Компрометация KEK в области notes не выдает ни одного email. Это то свойство сегрегации, которого однокоренный дизайн дать не может.

Последний пункт: связка ключей по умолчанию сохраняет старые KEK. Они не удаляются, просто перестают быть активными для записей — где-то всегда есть еще одна строка, зашифрованная под старым ключом, пока вы не доказали обратное.

6. Ротация — ленивая лучше жадной

Есть две стратегии для “ротировать ключ”. Многие команды представляют себе неправильную.

Жадная ротация — стратегия, которую большинство людей рисует на доске: когда приходит новый ключ, перешифровать каждую существующую строку, затем вывести старый из обращения. Работает для таблицы в 10 000 строк. На таблице в 100 миллионов строк это outage, а на мультиарендной базе это многодневная миграция с процедурами отката и множеством совещаний.

Ленивая ротация — стратегия, которая действительно масштабируется. Когда появляется новый ключ:

  1. Новые записи используют новый ключ.

  2. Существующие строки остаются ровно такими, какими они есть — зашифрованными старым ключом — а старый ключ удерживается в keychain.

  3. Чтения берут kid из конверта каждой строки и расшифровывают тем ключом, который ее обернул.

Со 100 миллионами существующих строк ничего не происходит. Никакого outage, никакой миграции, никакого плана отката. Цена в том, что старый ключ должен остаться, что по сути бесплатно.

Библиотека включает ротацию одним fluent-вызовом. Сама проверка — метод ShouldRotate, показанный выше:

services.AddEncryptedProperties(cfg => cfg    .WithFileRsaKeyProvider("rsa-key.pem", "rsa-v1")    .WithDatabaseKeyChain(SqlClientFactory.Instance, connectionString)    .WithKeyChainRotation(policy =>    {        policy.KeyRotateAfter = TimeSpan.FromDays(90);    }));

Через 90 дней следующая запись для данного назначения запускает генерацию KEK, старый KEK становится IsActive = false и остается в таблице, а жизнь продолжается.

Когда же действительно нужно перешифровывать жадно? Только когда есть причина, по которой конкретный старый ключ не может продолжать существовать: правдоподобное подозрение в компрометации, регулятор, требующий “нет данных старше X, зашифрованных ключом Y”. Тогда вы запускаете фоновую задачу, которая читает каждую затронутую строку, расшифровывает старым KEK, перезаписывает (что шифрует новым KEK), и как только счетчик дойдет до нуля, можно удалить старую запись. Это выполнимо, просто это не дефолтный путь ротации.

Вторая половина ротации — аудитопригодность — та часть, что превращается в вопрос комплаенса. Ревьюверы хотят знать: когда поменялся ключ? Кто запустил? Падали ли расшифровки в окне после? Библиотека генерирует ровно четыре event id для этого разговора:

// src/EfCore.EncryptedProperties/EncryptedPropertiesEventIds.cspublic static readonly EventId KeyCreated         = new(1000, nameof(KeyCreated));public static readonly EventId KeyRotated         = new(1001, nameof(KeyRotated));public static readonly EventId KeyPreloadFailed   = new(1002, nameof(KeyPreloadFailed));public static readonly EventId DecryptionFailed   = new(1003, nameof(DecryptionFailed));

Лог-строка KeyRotated несет каждое поле, которое запросит ревьювер — id старого ключа, id нового ключа, какая версия мастер-ключа обернула каждый из них, обе метки создания:

_logger.LogInformation(    EncryptedPropertiesEventIds.KeyRotated,    "Rotated encrypted property KEK for purpose {Purpose} from key {OldKeyId} to key {NewKeyId}. " +    "Old RSA key {OldRsaKeyId}, new RSA key {NewRsaKeyId}. " +    "Old created at {OldCreatedAt}; new created at {NewCreatedAt}.",    ...);

Отправляйте события ротации в аудит-лог по умолчанию — ревьювер спросит.

7. Где живет мастер-ключ?

Три формы развертывания в порядке возрастания операционной зрелости:

В памяти. Провайдер держит инстанс RSA, существующий на время жизни процесса и исчезающий с его завершением. Полезно для тестов, локальных демо и интеграционных тестовых фикстур, где не хочется управлять состоянием на диске. Не для чего-то, что вы намерены прочитать завтра.

services.AddEncryptedProperties(cfg => cfg    .WithInMemoryRsaKeyProvider(RSA.Create(2048), "test-rsa-v1")    .WithInMemoryKeyChain());

Файл на диске (PEM). Провайдер указывает на PEM-файл. Если файл отсутствует при старте, он генерирует свежий RSA-ключ и записывает его. Это форма “самохостовое, одна машина”, и для нее она хорошо работает — небольшие сервисы, on-prem-инсталляции, хобби-проекты, где операционная поверхность должна оставаться маленькой:

// src/EfCore.EncryptedProperties/Providers/FileRsaKeyProvider.cspublic FileRsaKeyProvider(string filePath, string keyId, int keySizeInBits = 2048){    KeyId = keyId;    if (File.Exists(filePath))    {        _rsa = RSA.Create();        _rsa.ImportFromPem(File.ReadAllText(filePath));    }    else    {        _rsa = RSA.Create(keySizeInBits);        var dir = Path.GetDirectoryName(filePath);        if (!string.IsNullOrEmpty(dir))            Directory.CreateDirectory(dir);        File.WriteAllText(filePath, _rsa.ExportRSAPrivateKeyPem());    }}

Компромисс честный: PEM-файл теперь это то, что ваша стратегия бэкапов должна учитывать. Потеряете PEM — и каждая зашифрованная колонка невосстановима. Относитесь к файлу как к любому другому секрету приложения — права на файловой системе, не под версионным контролем, включен в процедуры DR.

Crypto API Store машины — неплохой вариант, особенно если ваш хост в составе домена где имеется централизованное управление сертификатами.

KMS — Azure Key Vault. Провайдер держит KeyClient, нацеленный на ключ в Key Vault. Локальный процесс никогда не видит приватный RSA-ключ. Чтобы обернуть свежий KEK, локальный процесс достает публичный ключ (закешированный) и шифрует. Чтобы развернуть существующий KEK, локальный процесс отправляет обернутые байты в Key Vault и получает обратно развернутый CEK:

// src/EfCore.EncryptedProperties/Providers/AzureKeyVaultRsaKeyProvider.cspublic async ValueTask<byte[]> UnwrapKeyAsync(    byte[] ciphertext,    string rsaKeyId,    CancellationToken cancellationToken = default){    // ...    var cryptoClient = _cryptoClients.GetOrAdd(        rsaKeyId,        keyId => new CryptographyClient(new Uri(keyId), _credential));    var result = await cryptoClient.UnwrapKeyAsync(        KeyWrapAlgorithm.RsaOaep256,        ciphertext,        cancellationToken);    return result.Key;}

Два следствия. Во-первых, UnwrapKeyAsync — сетевой вызов, поэтому менеджер связки ключей агрессивно кеширует развернутые KEK в памяти. Во-вторых, операционная история драматически лучше: RBAC на ключе, аудит-логи каждого wrap/unwrap, soft-delete и purge protection, и приватный ключ, который никогда не извлекаем из vault.

В трех случаях на диске оказывается один и тот же конверт. Но разница “украденного ноутбука” между файловым развертыванием и Key Vault огромна — в первом случае у атакующего PEM и он может расшифровывать как угодно, во втором у него непрозрачные блобы и нет способа дотянуться до vault.

8. Задача поиска — и меню плохих ответов

Это тот раздел, который архитектурные диаграммы обычно пропускают. Если вы шифруете колонку правильно — свежий CEK на запись, случайный IV, шифротекст AES-GCM — тогда один и тот же открытый текст каждый раз дает разный шифротекст. Именно это свойство безопасности вам нужно. Именно оно и делает невозможным для базы данных ответить на:

// Это не будет работать :(var customer = await db.Customers    .SingleOrDefaultAsync(c => c.Email == "alice@example.com");

WHERE сравнивает два блоба шифротекста, которые никогда не совпадут — хотя открытые тексты совпадают. Индексы по зашифрованным колонкам бесполезны для поиска по равенству. JOIN бесполезен. LIKE безнадежен. ORDER BY возвращает строки, отсортированные по случайному шифротексту.

Есть пять ответов, все с компромиссами. Я назову, что дает и чем платит каждый — бесплатного варианта здесь нет.

Вариант A — Детерминированное шифрование. Убрать случайность: шифровать детерминированной схемой (AES-SIV с фиксированным выводом nonce, или просто AES-ECB на коротком значении фиксированного размера, если уж совсем надо), чтобы один и тот же открытый текст всегда давал один и тот же шифротекст. Дает запросы по равенству, JOIN и индексы по равенству. Платит паттернами равенства — у любого с доступом на чтение к колонке видно, какие строки делят значение. Для идентификаторов высокой кардинальности (например, id из внешних систем) это, может, нормально. Для полей низкой кардинальности — страна, пол, булевый статус — это почти идеальная цель для частотного анализа.

Вариант B — Слепой индекс (колонка HMAC). Оставить шифротекст недетерминированным, но хранить вторую колонку, содержащую HMAC(secret, normalized_plaintext), и индексировать ее обычным способом. Чтобы найти по email, вычислить HMAC от значения запроса и искать по индексу. Дает поиск по равенству, причем индекс всегда указывает на строку шифротекста. Платит равенством по индексируемым значениям (один и тот же открытый текст → один и тот же HMAC), но не затрагивает саму колонку шифротекста, и вы поколоночно контролируете, какие поля получат индекс. Это стандартный прагматичный паттерн, к которому приходит большинство промышленных .NET-приложений.

Вариант C — Шифрование, сохраняющее порядок / раскрывающее порядок. Специальные схемы шифрования, чей шифротекст сохраняет порядок открытого текста, так что range-запросы (WHERE age BETWEEN ... AND ...) работают. Дает range-запросы. Платит порядком каждого значения — а это много: для колонки дат рождения атакующий по сути получает колонку. Разумно для очень узких сценариев, опасно как общий паттерн.

Вариант D — Searchable symmetric encryption (SSE) / полностью гомоморфное шифрование (FHE). Криптографические схемы, позволяющие операции запросов прямо на шифротексте. Дает настоящий поиск без раскрытия открытого текста, расплата в теории гораздо менее опасная чем у вариантов выше. Производительность академически медленная, реальных .NET-реализаций мало, а математика непрощающая. Реалистично для очень небольшого числа команд.

Вариант E — Не искать по зашифрованному хранилищу. Оставить зашифрованную колонку непрозрачной. Поместить искабельное представление где-нибудь еще — отдельный индексный сервис, поисковый движок типа Elasticsearch со своим контролем доступа, или in-memory lookup, заполняемый на записи. Дает полные свойства шифрования на колонке базы. Платит отдельным куском инфраструктуры в обслуживании.

В матричной форме:

Вариант

Поиск по равенству

Range-поиск

Что утекает

Операционная стоимость

A: Детерминированный

Да

Нет

Паттерны равенства

Низкая

B: Слепой индекс

Да

Нет

Равенство по индексируемому значению

Низкая–средняя

C: Сохраняющий порядок

Да

Да

Полный порядок

Средняя

D: SSE / FHE

Да (ограниченно)

Возможно

Меньше, но специализировано

Очень высокая

E: Out-of-band индекс

Да

Да (зависит от хранилища)

Ничего на зашифрованной колонке

Средняя–высокая (вторая система)

EfCore.EncryptedProperties твердо находится в лагере непрозрачной колонки: не запрашивайте зашифрованные колонки по открытому тексту, для поиска держите отдельную незашифрованную lookup-колонку, например, нормализованный хеш. Это вариант B, примененный на уровне приложения, а не зашитый в библиотеку. Библиотека шифрует колонку, вы добавляете соседнюю колонку с хешем, если нужен поиск. Граница намеренная — какая хеш-функция правильная, нужно ли ее солить на tenant-а, какие правила нормализации (lowercased? Unicode-normalized? trimmed?) — это политические вопросы, на которые библиотека не может ответить за вас.

Как стартовый вариант форма паттерна слепого индекса в EF Core может выглядеть так:

public sealed class Customer{    public Guid Id { get; set; }    [Encrypted("email")]    public string Email { get; set; } = string.Empty;    // Слепой индекс: HMAC(server-side-secret, Email.Trim().ToLowerInvariant())    public string EmailHash { get; set; } = string.Empty;}

EmailHash имеет индекс в базе. Записи вычисляют HMAC, поиски вычисляют HMAC значения запроса и матчат по EmailHash. Зашифрованная колонка остается нетронутой, и у вас есть намеренная, узкая утечка — “эти строки делят email” — которую вы сознательно приняли.

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

9. Проработанный пример — переносим все на сущность

Чтобы сделать все это конкретным, вот сквозной разбор — адаптированный из примера WebApi, поставляемого с библиотекой.

Сущность. Два зашифрованных свойства на разных назначениях, одно прозрачное (Email), одно с асинхронной расшифровкой (SecretNotes):

public sealed class Customer{    public Guid Id { get; set; }    public string Name { get; set; } = string.Empty;    [Encrypted("email")]    public string Email { get; set; } = string.Empty;    [Encrypted("notes")]    public EncryptedValue<string> SecretNotes { get; set; } = default!;}

Подключение DI.

builder.Services.AddEncryptedProperties(cfg =>{    cfg.WithFileRsaKeyProvider(rsaKeyFile, rsaKeyId);    cfg.WithDatabaseKeyChain(SqlClientFactory.Instance, connectionString);    cfg.WithKeyChainPreloadOnStartup();});builder.Services.AddDbContext<ApiSampleDbContext>((sp, options) =>{    options.UseSqlServer(connectionString);    options.UseEncryptedProperties(sp);});

Для промышленного развертывания замените WithFileRsaKeyProvider на WithAzureKeyVaultRsaKeyProvider, остальное остается. WithKeyChainPreloadOnStartup() добавляет hosted service, который разворачивает каждый сохраненный KEK во время старта хоста — так что если мастер-ключ недоступен или неверно настроен, приложение падает быстро, а не кидает исключение на первом зашифрованном чтении.

Что попадает в базу. Customers.Emailstring-колонка, содержащая пятисегментный base64url JWE — header.wrappedCek.iv.ciphertext.tag. kid в заголовке указывает на строку в таблице KEK, которая хранит RSA-обернутый материал KEK. Бэкап базы содержит ноль открытых ключей и ноль открытых значений, PEM-файл (или ключ Key Vault) — единственное, что может его открыть.

Что происходит в день ротации. С KeyRotateAfter = TimeSpan.FromDays(90) следующая запись после 90-го дня запускает GetActiveKeyAsync, которая видит, что активный KEK прошел политику, генерирует новый, сохраняет его с IsActive = true и переключает старый на IsActive = false. Дальше новые записи получают новый kid, старые строки сохраняют свой старый kid, чтения работают для обоих. Приложение логирует одно событие KeyRotated с id старого и нового ключа, обеими версиями RSA-ключа и обеими метками создания — ровно то, что спросит аудит. Никакой миграции строк не запускается. Никакая customer-таблица не итерируется.

Что происходит, когда расшифровка падает. Возможно, PEM был ротирован без сохранения старого ключа. Возможно, строка пришла из плохой миграции. Библиотека логирует событие DecryptionFailed с типом сущности, именем свойства, назначением и kid — достаточно для триажа без grep-а по кодовой базе.

10. Заключение — чеклист для вашего собственного дизайна

Чеклист для design review:

  • Используйте authenticated encryption. AES-GCM или ChaCha20-Poly1305. Никогда ECB. CBC — только с отдельным, правильно сконструированным MAC.

  • Никогда не переиспользуйте IV с одним и тем же ключом. Случайные 96-битные IV — безопасный дефолт.

  • Конверт. Отдельные CEK / KEK / мастер-ключ. “Один AES-ключ в конфиге” не переживет первого же запроса на ротацию.

  • Мастер-ключ в KMS в продакшене. Azure Key Vault, AWS KMS, GCP KMS, Vault, OS Crypto API. PEM на диске нормален только для самохостового однокомпьютерного развертывания.

  • Одна связка ключей на домен данных. Независимая ротация, независимый blast radius.

  • Ленивая ротация. Новые записи получают новый ключ, старые строки расшифровываются по kid из связки. Жадное перешифрование — редкое исключение.

  • Логируйте каждое событие ключа. Created, rotated, decryption failed, preload failed. Ревьювер аудита безопасности спросит.

  • Выбирайте политику поиска явно. Детерминированно, слепой индекс, out-of-band или действительно непрозрачно.

  • Открытый текст все еще в вашем процессе. Encryption at rest — забор вокруг хранилища данных, а не вокруг приложения.

Если вы строите это в .NET на EF Core, вы можете использовать EfCore.EncryptedProperties — она поставляет все, что выше: AES-256-GCM со случайными IV, JWE-конверты с kid на строку, связки ключей по назначениям, провайдеры file/in-memory/Azure Key Vault и ленивая ротация с событиями аудита.

Если вы строите свое, чеклист выше — то, что имеет значение. Библиотека — один из способов это выразить. Чего нет — так это способа пропустить дизайн-работу.

ссылка на оригинал статьи https://habr.com/ru/articles/1036158/