От темы в журнале к проектной практике
Соответствующие страницы услуг и технологий к статье
При AES-шифровании Delphi на практике редко дело в «самом AES», чаще — в сопутствующих условиях: данные необходимо обрабатывать как поток (файлы, BLOBы, резервные копии), старые форматы должны оставаться читаемыми, а в эксплуатации требуется отлаживаемость (заголовок, версионирование) и безопасные настройки по умолчанию (Salt/IV — случайные, без повторного использования). Поэтому этот фрагмент исходного кода показывает не только «Encrypt/Decrypt», но и небольшой, надёжный формат с заголовком, версией, солью и IV — плюс PBKDF2 для вывода ключа и место, где уместно дополнить проверку целостности.
Почему «AES-шифрование строки» почти никогда не достаточно
В проприетарном корпоративном ПО шифрование обычно встречается в трёх местах: (1) конфигурация/секреты (например, учётные данные), (2) обменные/экспортные файлы и (3) данные в покое (например, архивы, контейнеры документов). Наивный подход «пароль → AES-ключ → строка внутрь/наружу» быстро даёт сбои:
- Повторное использование IV: В режимах вроде CBC или GCM вектор инициализации (IV) должен быть уникален для каждой операции шифрования. Постоянный IV — это утечка, даже если пароль сильный.
- Ключ из пароля без KDF: Использование пароля напрямую как ключа (или единоразовое хеширование) открывает возможность оффлайн-атак. KDF (Key Derivation Function), например PBKDF2, целенаправленно замедляет атакующих.
- Отсутствие версии формата: Без заголовка/версии вы вряд ли сможете изменить число итераций, алгоритм или параметры позже, не сделав старые данные «бесхозными».
- Отсутствие целостности: AES-CBC шифрует, но не предотвращает модификации. Без аутентификации (например, HMAC или AEAD как GCM) вы получите проблемы с подменой битов/паддингом и труднодиагностируемые ошибки.
Суть этого материала: небольшой контейнерный формат, поддерживающий потоковую обработку, версионируемый и избегающий типичных ошибок.
Шифрование AES Delphi с заголовком, солью, IV и PBKDF2
Мы определяем простой контейнерный формат, который также пригоден для BLOBов в базе данных или в полезной нагрузке сообщений:
- Magic: 4 байта, например
NBAE(быстрая проверка «это наш формат?») - Version: 1 байт (позволяет миграцию)
- Параметры KDF: число итераций (4 байта)
- Salt: 16 байт (случайно для каждого файла)
- IV: 16 байт (случайно для каждого файла для AES-CBC)
- Ciphertext: зашифрованные полезные данные (поддерживает стриминг)
Важно: Salt и IV не являются секретом. Они должны быть уникальны для каждой операции шифрования. Пароль остаётся секретом; из него выведённый ключ не сохраняется.
AES-шифрование Delphi в потоке: запись/чтение контейнера
Код умышленно написан как «чертёж»: чётко разделённые функции, проверяемые заголовки, никаких скрытых глобальных переменных. Для AES и PBKDF2 многие команды используют проверенную криптобиблиотеку (например, DEC). Фрагмент демонстрирует формат и паттерн стриминга; вызовы AES/PBKDF2 инкапсулированы так, чтобы вы могли при необходимости заменить их в зависимости от используемой библиотеки.
unit Nb.AesContainer;
interface
uses
System.SysUtils, System.Classes, System.NetEncoding;
type
ENbCryptoError = class(Exception);
TNbAesContainer = class
public
class procedure EncryptStreamToStream(const AIn: TStream; const AOut: TStream;
const APassword: string; const AIterations: Cardinal = 200000);
class procedure DecryptStreamToStream(const AIn: TStream; const AOut: TStream;
const APassword: string);
class function EncryptBytesToBase64(const APlain: TBytes; const APassword: string): string;
class function DecryptBase64ToBytes(const ACipherB64: string; const APassword: string): TBytes;
end;
implementation
const
CMagic: array[0..3] of AnsiChar = ('N','B','A','E');
CVersion: Byte = 1;
CSaltLen = 16;
CIvLen = 16;
type
TNbHeaderV1 = packed record
Magic: array[0..3] of AnsiChar;
Version: Byte;
Iterations: Cardinal; // little endian
Salt: array[0..CSaltLen-1] of Byte;
IV: array[0..CIvLen-1] of Byte;
end;
// --- Abhängigkeiten, die Sie je nach Crypto-Stack implementieren ---
procedure FillRandomBytes(var B: TBytes);
begin
// Für Kryptozufall: OS-CSPRNG verwenden (Windows BCryptGenRandom,
// Linux getrandom/urandom). Hier absichtlich als Platzhalter.
raise ENbCryptoError.Create('FillRandomBytes: CSPRNG nicht angebunden');
end;
function PBKDF2_HMAC_SHA256(const APassword: string; const ASalt: TBytes;
const AIterations, AKeyLen: Cardinal): TBytes;
begin
// Implementierung z. B. mit DEC (PBKDF2) oder anderer Bibliothek.
// Ergebnis: AKeyLen Bytes.
raise ENbCryptoError.Create('PBKDF2_HMAC_SHA256: nicht angebunden');
end;
procedure AES256_CBC_EncryptStream(const AKey, AIV: TBytes; const AIn, AOut: TStream);
begin
// Implementierung per Bibliothek:
// - KeyLen = 32 Bytes
// - IVLen = 16 Bytes
// - PKCS#7 Padding
// Wichtig: Stream-orientiert verarbeiten, nicht alles in Memory.
raise ENbCryptoError.Create('AES256_CBC_EncryptStream: nicht angebunden');
end;
procedure AES256_CBC_DecryptStream(const AKey, AIV: TBytes; const AIn, AOut: TStream);
begin
raise ENbCryptoError.Create('AES256_CBC_DecryptStream: nicht angebunden');
end;
// --- Helper ---
procedure WriteHeaderV1(const AOut: TStream; const H: TNbHeaderV1);
begin
if AOut.Write(H, SizeOf(H)) <> SizeOf(H) then
raise ENbCryptoError.Create('Header konnte nicht geschrieben werden');
end;
function ReadHeaderV1(const AIn: TStream): TNbHeaderV1;
var
H: TNbHeaderV1;
begin
if AIn.Read(H, SizeOf(H)) <> SizeOf(H) then
raise ENbCryptoError.Create('Header unvollständig');
if (H.Magic[0] <> CMagic[0]) or (H.Magic[1] <> CMagic[1]) or
(H.Magic[2] <> CMagic[2]) or (H.Magic[3] <> CMagic[3]) then
raise ENbCryptoError.Create('Kein gültiger Container (Magic stimmt nicht)');
if H.Version <> CVersion then
raise ENbCryptoError.CreateFmt('Unbekannte Container-Version: %d', [H.Version]);
if (H.Iterations < 10000) or (H.Iterations > 5000000) then
raise ENbCryptoError.Create('Iterationszahl außerhalb plausibler Grenzen');
Result := H;
end;
class procedure TNbAesContainer.EncryptStreamToStream(const AIn, AOut: TStream;
const APassword: string; const AIterations: Cardinal);
var
H: TNbHeaderV1;
Salt, IV, Key: TBytes;
begin
if APassword = '' then
raise ENbCryptoError.Create('Passwort darf nicht leer sein');
// Salt/IV erzeugen
SetLength(Salt, CSaltLen);
SetLength(IV, CIvLen);
FillRandomBytes(Salt);
FillRandomBytes(IV);
// Header befüllen
Move(CMagic[0], H.Magic[0], Length(CMagic));
H.Version := CVersion;
H.Iterations := AIterations;
Move(Salt[0], H.Salt[0], CSaltLen);
Move(IV[0], H.IV[0], CIvLen);
WriteHeaderV1(AOut, H);
// Key ableiten (32 Bytes für AES-256)
Key := PBKDF2_HMAC_SHA256(APassword, Salt, AIterations, 32);
// Nutzdaten verschlüsseln (Ciphertext folgt direkt nach Header)
AES256_CBC_EncryptStream(Key, IV, AIn, AOut);
end;
class procedure TNbAesContainer.DecryptStreamToStream(const AIn, AOut: TStream;
const APassword: string);
var
H: TNbHeaderV1;
Salt, IV, Key: TBytes;
begin
if APassword = '' then
raise ENbCryptoError.Create('Passwort darf nicht leer sein');
H := ReadHeaderV1(AIn);
SetLength(Salt, CSaltLen);
SetLength(IV, CIvLen);
Move(H.Salt[0], Salt[0], CSaltLen);
Move(H.IV[0], IV[0], CIvLen);
Key := PBKDF2_HMAC_SHA256(APassword, Salt, H.Iterations, 32);
// Entschlüsseln ab aktueller Stream-Position (nach Header)
AES256_CBC_DecryptStream(Key, IV, AIn, AOut);
end;
class function TNbAesContainer.EncryptBytesToBase64(const APlain: TBytes;
const APassword: string): string;
var
InS, OutS: TBytesStream;
begin
InS := TBytesStream.Create(APlain);
try
OutS := TBytesStream.Create;
try
EncryptStreamToStream(InS, OutS, APassword);
Result := TNetEncoding.Base64.EncodeBytesToString(OutS.Bytes, 0, OutS.Size);
finally
OutS.Free;
end;
finally
InS.Free;
end;
end;
class function TNbAesContainer.DecryptBase64ToBytes(const ACipherB64,
APassword: string): TBytes;
var
Cipher: TBytes;
InS, OutS: TBytesStream;
begin
Cipher := TNetEncoding.Base64.DecodeStringToBytes(ACipherB64);
InS := TBytesStream.Create(Cipher);
try
OutS := TBytesStream.Create;
try
DecryptStreamToStream(InS, OutS, APassword);
Result := OutS.Bytes;
SetLength(Result, OutS.Size);
finally
OutS.Free;
end;
finally
InS.Free;
end;
end;
end.Назначение: Минимальный контейнер, подходящий для файлов и BLOB-объектов, включая версионирование и параметры KDF. Ограничения: Необходимо обеспечить реальную привязку к CSPRNG (криптографически стойкий источник случайных чисел из операционной системы) и надёжную реализацию AES/PBKDF2. Подводные камни: Не используйте «какой‑нибудь» Random (не Random()), не применяйте фиксированные IV, и при расшифровке предусмотрите однозначную обработку ошибок (неправильный пароль vs. повреждённые данные). Варианты: вместо CBC предпочтительнее AEAD (см. ниже), либо расширить заголовок идентификатором алгоритма и HMAC.
Целостность: почему AES-CBC в эксплуатации сам по себе рискован
AES-CBC всё ещё встречается во многих наследуемых контекстах и может работать, если вы дополнительно применяете механизм контроля целостности. Без контроля целостности злоумышленник может модифицировать шифротекст; даже при отсутствии активного атакующего ошибки передачи или повреждённые слои хранения приводят к трудно диагностируемым «padding»-ошибкам.
Практические варианты:
- Encrypt-then-HMAC: После шифротекста записать HMAC (например HMAC-SHA-256) по Header+Ciphertext. При чтении сначала проверить HMAC, затем расшифровать. Для этого желательно вывести из PBKDF2 два ключа (например 64 байта: 32 для AES, 32 для HMAC), вместо повторного использования одного и того же ключа.
- AES-GCM: AEAD-режим (Authenticated Encryption with Associated Data). Даёт шифротекст и аутентификационный тег. Поля заголовка можно аутентифицировать как «AAD», не шифруя их.
Если вам нужно остаться на CBC (например, ради совместимости), то Encrypt-then-HMAC — это надёжное дополнение. Для новых форматов имеет смысл использовать GCM, поскольку он обеспечивает аутентификацию «из коробки» и делает картины ошибок понятнее.
Особо важно: «криптографический случайный источник» и почему System.Hash не годится
Типичный наследуемый рефлекс в Delphi-проектах: «Мы просто берём SHA256 от метки времени + чего‑нибудь и получаем Random». Это ненадёжная основа. Для Salt и IV вам нужен CSPRNG (Cryptographically Secure Pseudo Random Number Generator) операционной системы. Под Windows это обычно BCrypt-API (CNG), под Linux — генератор ядра вроде getrandom() или /dev/urandom. Практическая разница в том, что CSPRNG сконструирован так, чтобы по наблюдаемым данным нельзя было предсказать последующие значения.
Архитектурный приём: инкапсулируйте это в небольшую unit «RandomProvider», которую можно мокать в тестах. Это решает сразу две задачи: воспроизводимые тесты (фиксированный seed в моке) и реальная безопасность в продакшене (OS-CSPRNG). Так вы предотвращаете ситуацию, когда в хотфиксе «быстро и просто» возвращают Random(), потому что это удобно.
Отладка и миграция наследия: версионирование — не роскошь
Заголовок нужен не только для «крипто‑аккуратности», но и для сопровождения:
- Настройка числа итераций: Число итераций PBKDF2 меняется с годами. С полем в заголовке вы сможете повысить его позже, не делая старые данные нечитаемыми.
- Смена формата: Версия 2 может, например, перейти на AES-GCM или добавить HMAC.
- Диагностика в полевых условиях: Magic/Version позволяют быстро проверять в логах и инструментах без необходимости расшифровывать данные.
Практический совет: реализуйте небольшой «Inspector», который считывает только заголовок (Magic/Version/Iterations) и записывает в лог. Так вы проясните многие вопросы поддержки («Какая здесь версия?») без работы с паролями.
Чистая миграция: „Read old, write new“ вместо Big Bang
Если вы заменяете старый формат (напр., фиксированный IV, отсутствие KDF, Blowfish/3DES или самодельный XOR), в Delphi-проектах зарекомендовала себя следующая схема: при чтении распознавайте несколько форматов (Magic/Version или эвристика обратной совместимости), при записи генерируйте только новый формат. Дополнительно, при успешном расшифровании можно в фоне выполнить ре-шифрование («lazy migration»), если это вписывается в процесс. Это сокращает риск отката и избавляет от необходимости «одновременно перешифровать всё» в окне технического обслуживания.
Потоки и многопоточность: типичные узкие места в Delphi
Шифрование часто выполняется в рабочих потоках (например, при экспорте, при загрузке в портал клиента, при записи больших архивов). Два момента, которые в Delphi-проектах регулярно проявляются:
- Позиции потока: перед шифрованием/расшифрованием задавайте чёткие контрактные положения: входной поток читается с текущей позиции, выходной поток записывается с текущей позиции. При повторном использовании потоков обязательно сознательно устанавливайте
Position := 0. - Пики использования памяти: избегайте «всё в Терабайтах». Подход на основе потоков особенно важен для больших файлов. Если ваша криптобиблиотека принимает только байтовые массивы, имеет смысл вложить дополнительную работу в переход к stream-ориентированной реализации или построить буферизующий адаптер.
Если вы шифруете в сервисах (в Windows- или Linux-Services), дополнительно позаботьтесь о корректном логировании исключений: «неверный пароль», «заголовок повреждён», «Tag/HMAC недействителен» — это разные эксплуатационные случаи и их следует различать. Важно: сообщения об ошибках не должны быть чрезмерно подробными для внешних клиентов (никакого «Padding неверен в блоке 7» как API-ошибка), но внутри логов подробности допустимы.
Когда подход оправдан — и где он может сломаться
Оправдан, когда вы: (a) надёжно храните зашифрованные данные экспорта/импорта, (b) эксплуатируете параллельно разные версии программ, (c) обрабатываете данные потоками или (d) нуждаетесь в чистом крипто-интерфейсе для нескольких модулей (клиент/сервер/утилиты).
Не подходит, если вы пытаетесь решить с его помощью «всё подряд»: за транспорт отвечает TLS, а не самодельная оболочка вокруг AES. Для секретов (пароли, токены) чаще уместнее системный хранилище секретов ОС или Vault. И если нужна межъязыковая совместимость, заголовки, порядок байтов (endianness) и кодировки должны быть задокументированы точно (или используйте уже зарекомендовавший себя формат).
Вывод: AES в Delphi — это меньше алгоритм, больше инженерия
Главный выигрыш этого набора приёмов — не в том, что «AES работает», а в наличии эксплуатационно пригодного формата: случайный salt и IV, версионированный заголовок, параметры PBKDF2 в нагрузке и потоково-ориентированная обработка. Для новых форматов по возможности добавляйте целостность (AES-GCM или Encrypt-then-HMAC). Это превращает «мы что-то шифруем» в компонент, который в корпоративных цифровых решениях остаётся поддерживаемым и мигрируемым даже через годы.
Если вам нужно интегрировать такой контейнер в существующий Delphi-ландшафт или корректно мигрировать его из унаследованного формата, имеет смысл провести краткий проверочный обзор архитектуры (управление ключами, версии форматов, эксплуатация/логирование). Подробности при необходимости обсудим в разговоре:
В предметной области также важную роль играют Delphi Aes и Pbkdf2 Delphi, когда интеграции, потоки данных и дальнейшая разработка должны корректно взаимодействовать.
Следующий шаг
Если из темы вырастет реальный проект, архитектуру, текущую систему и эксплуатацию следует рассматривать совместно на ранних этапах.
Wir unterstuetzen nicht nur bei Einzelfragen, sondern auch dann, wenn aus Source-Schnipseln, Legacy-Themen oder Portalideen ein belastbares Unternehmensprojekt werden soll.
- Текущее состояние, целевое состояние и технические риски оцениваются совместно.
- REST, доступ к данным, порталы и развертывание не переносятся на более поздние этапы.
- Вы заранее видите, какой путь экономически и эксплуатационно жизнеспособен.