Техническа статия

Високоскоростно AES-256 PDF криптиране за масивни документи

Криптирането на 2 GB PDF звучи като проблем с поточно предаване (streaming): отворете файла, прекарайте два гигабайта през AES-256, запишете резултата. Този мисловен модел е грешен по начин, който решава целия бюджет за производителност. ISO 32000-1 §7.6 задава гранулярността на PDF криптирането на отделния обект — всеки поток (stream) и всеки низ (string) се криптират отделно, всеки със собствен инициализационен вектор (IV) и собствено допълване (padding). 2 GB сканиран архив с 500 000 обекта са 500 000 малки CBC операции, а не един дълъг проход, и в този мащаб фиксираната цена около всяка операция има по-голямо значение от AES аритметиката вътре в нея

Тази статия е за тази фиксирана цена: къде отива времето, когато кодът на Delphi прилага AES-256 към много големи документи, и как да си го върнете. За частта с настройката — пароли, флагове за разрешения, извикването за съвместимост на ревизия 5 спрямо 6 — вижте придружаващата статия за конфигуриране на AES-256 криптиране в HotPDF; нищо от това не се повтаря тук

Половин милион CBC операции, а не един проход

Скелетът на файла остава в обикновен текст (plaintext). Таблици с кръстосани препратки (cross-reference tables), номера на обекти, ключове в речника, дървото на страниците: нищо от това не е криптирано, което е начинът, по който един четец може да локализира обекти, преди да е валидирал парола. Това, което стандартът криптира, е съдържанието — данни на потоци (stream data) като описания на страници, изображения, шрифтове и прикачени файлове, плюс низове като стойности на метаданни и текст на анотации. Под крипто филтъра (crypt filter) AES-256 всеки от тях се обработва сам по себе си: свеж произволен 16-байтов IV, CBC върху байтовете, допълване на блокове (block padding) до 16-байтова граница и IV, записан в ясен текст (in the clear) преди шифротекста (ciphertext)

Следват две последствия. Първо, шифротекстът винаги е по-дълъг от обикновения текст: IV добавя 16 байта, а допълването добавя още 1 до 16, така че низ от 100 байта заема 128 байта на диска, а празен поток все пак произвежда 32. Код, който оразмерява изходния буфер според дължината на входа, или записва обратно само толкова байтове, колкото е прочел, произвежда файлове, които не успяват да се декриптират в последния блок на всеки обект. Второ, цената проследява броя на обектите, а не само броя на байтовете. Един сканиран архив концентрира байтовете си в няколко големи потока с изображения, но носи стотици хиляди кратки потоци и малки низове, където разходите за операция (per-operation overhead), а не AES, са сметката

Единствената милост в дизайна на AES-256 е обработката на ключове. Манипулаторите за сигурност (Security handlers) до ревизия 4 извличаха (derived) отделен ключ за всеки обект чрез хеширане на ключа на файла заедно с номерата на обекта и генерацията, принуждавайки създаването на свеж график на ключовете (key schedule) всеки път. Схемите /V 5 се отказаха от извличането на база обект: един случаен 256-битов ключ на файла криптира всеки обект в документа. Този факт лицензира всяка оптимизация по-долу — скъпото криптографско състояние може да бъде изградено веднъж за файл, а не веднъж за обект

Речникът /Encrypt от ревизия 6 (R6): едно бавно отваряне, евтини обекти

Документ от ревизия 6 декларира своята схема в речника /Encrypt на трейлъра, а записите, които имат значение, се побират в няколко реда:

/Filter /Standard
/V 5  /R 6  /Length 256
/CF << /StdCF << /CFM /AESV3  /Length 32  /AuthEvent /DocOpen >> >>
/StmF /StdCF    /StrF /StdCF
/O ...48 bytes...   /U ...48 bytes...
/OE ...32 bytes...  /UE ...32 bytes...
/Perms ...16 bytes...  /P -3904  /EncryptMetadata true

/V 5 избира 256-битовата ключова архитектура, а /R 6 втвърденото (hardened) ръкостискане по ISO 32000-2. /CF дефинира наименувания крипто филтър — /AESV3 означава AES-256 в режим CBC с добавения отпред (prepended) IV — а /StmF и /StrF присвояват този филтър съответно на потоци и низове. /O, /U, /OE и /UE съдържат материала за проверка на паролата и обвиване на ключа (key-wrapping), а /Perms носи AES-криптирано копие на битовете за разрешение, така че враждебен редактор да не може тихомълком да преобърне (flip) /P

Структурата на разходите се крие в /OE и /UE. Разопаковането (unwrapping) на ключа на файла от тях изпълнява Алгоритъм 2.B, итерирана функция за извличане на ключове (key-derivation function - KDF), която верижно свързва (chaining) рундове SHA-256, SHA-384 и SHA-512 — поне 64 от тях, с правило за спиране, зависещо от данните — изградена умишлено бавно, така че отгатването на пароли да остане скъпо. Тази цена се плаща веднъж, когато писателят (writer) създава файла, и веднъж, когато четецът (reader) го отваря, едноцифрени милисекунди всеки. При файл с половин милион обекта KDF е шум, и ако запазването е бавно, Алгоритъм 2.B не е заподозреният; цикълът на обект (per-object loop) е

Повторно използване на манипулатора (handle) на ключа, повторно използване на scratch буфера

Наивната реализация е спретната помощна функция: помощник EncryptAes256Cbc, който отваря CNG доставчика (provider) на Windows, избира CBC, генерира ключовия обект, криптира един буфер и разрушава (tears down) всичко. Коректно, тестваемо (unit-testable) и катастрофално вътре в цикъл с 500 000 итерации. Документацията на Microsoft маркира (flags) BCryptOpenAlgorithmProvider като скъпа и препоръчва кеширане на манипулатора (handle), а BCryptGenerateSymmetricKey изпълнява пълния график на ключовете за AES и заделя (allocates) състояние на доставчика — чиста загуба (pure waste), когато ключът никога не се променя в целия документ

RTL на Delphi не доставя bcrypt импортен модул (import unit), така че декларирайте входните точки директно. Класът по-долу изгражда цялото криптографско състояние веднъж и след това криптира произволен брой обекти без заделяне в стационарно състояние (steady-state allocation):

uses
  Winapi.Windows, System.SysUtils, System.Classes;

const
  BCRYPT_AES_ALGORITHM  = 'AES';
  BCRYPT_CHAINING_MODE  = 'ChainingMode';
  BCRYPT_CHAIN_MODE_CBC = 'ChainingModeCBC';
  BCRYPT_OBJECT_LENGTH  = 'ObjectLength';
  BCRYPT_BLOCK_PADDING            = $00000001;
  BCRYPT_USE_SYSTEM_PREFERRED_RNG = $00000002;

type
  NTSTATUS = Integer;
  BCRYPT_HANDLE = Pointer;

function BCryptOpenAlgorithmProvider(out hAlg: BCRYPT_HANDLE; AlgId,
  Impl: PWideChar; Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';
function BCryptCloseAlgorithmProvider(hAlg: BCRYPT_HANDLE;
  Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';
function BCryptSetProperty(hObj: BCRYPT_HANDLE; Prop: PWideChar; Input: PByte;
  cbInput, Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';
function BCryptGetProperty(hObj: BCRYPT_HANDLE; Prop: PWideChar; Output: PByte;
  cbOutput: ULONG; out cbResult: ULONG; Flags: ULONG): NTSTATUS; stdcall;
  external 'bcrypt.dll';
function BCryptGenerateSymmetricKey(hAlg: BCRYPT_HANDLE;
  out hKey: BCRYPT_HANDLE; KeyObj: PByte; cbKeyObj: ULONG; Secret: PByte;
  cbSecret: ULONG; Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';
function BCryptDestroyKey(hKey: BCRYPT_HANDLE): NTSTATUS; stdcall;
  external 'bcrypt.dll';
function BCryptEncrypt(hKey: BCRYPT_HANDLE; Input: PByte; cbInput: ULONG;
  Padding: Pointer; IV: PByte; cbIV: ULONG; Output: PByte; cbOutput: ULONG;
  out cbResult: ULONG; Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';
function BCryptGenRandom(hAlg: BCRYPT_HANDLE; Buffer: PByte;
  cbBuffer, Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';

procedure CngCheck(Status: NTSTATUS; const Api: string);
begin
  if Status <> 0 then
    raise Exception.CreateFmt('%s failed, NTSTATUS 0x%.8x',
      [Api, Cardinal(Status)]);
end;

type
  TPdfObjectEncryptor = class
  private
    FAlg: BCRYPT_HANDLE;
    FKey: BCRYPT_HANDLE;
    FKeyObject: TBytes;  // CNG работно пространство за обект-ключ, заделено веднъж
    FScratch: TBytes;    // scratch буфер за шифротекст, нараства и след това остава
  public
    constructor Create(const FileKey: TBytes);
    destructor Destroy; override;
    procedure EncryptObject(const Plain: TBytes; Dest: TStream);
  end;

constructor TPdfObjectEncryptor.Create(const FileKey: TBytes);
var
  Mode: string;
  ObjLen, Got: ULONG;
begin
  inherited Create;
  if Length(FileKey) <> 32 then
    raise Exception.Create('AES-256 file key must be 32 bytes');
  CngCheck(BCryptOpenAlgorithmProvider(FAlg, BCRYPT_AES_ALGORITHM, nil, 0),
    'BCryptOpenAlgorithmProvider');
  Mode := BCRYPT_CHAIN_MODE_CBC;
  CngCheck(BCryptSetProperty(FAlg, BCRYPT_CHAINING_MODE,
    PByte(PWideChar(Mode)), (Length(Mode) + 1) * SizeOf(WideChar), 0),
    'BCryptSetProperty');
  CngCheck(BCryptGetProperty(FAlg, BCRYPT_OBJECT_LENGTH, PByte(@ObjLen),
    SizeOf(ObjLen), Got, 0), 'BCryptGetProperty');
  SetLength(FKeyObject, ObjLen);
  // Графикът на ключовете за AES се изгражда веднъж тук и се използва повторно за всеки обект
  CngCheck(BCryptGenerateSymmetricKey(FAlg, FKey, PByte(FKeyObject), ObjLen,
    PByte(FileKey), 32, 0), 'BCryptGenerateSymmetricKey');
end;

destructor TPdfObjectEncryptor.Destroy;
begin
  if FKey <> nil then
    BCryptDestroyKey(FKey);
  if FAlg <> nil then
    BCryptCloseAlgorithmProvider(FAlg, 0);
  inherited;
end;

procedure TPdfObjectEncryptor.EncryptObject(const Plain: TBytes; Dest: TStream);
var
  IV, IVWork: array[0..15] of Byte;
  Need, Written: ULONG;
  Src: PByte;
begin
  // Свеж произволен IV за всеки обект; той пътува в ясен текст преди данните
  CngCheck(BCryptGenRandom(nil, @IV[0], 16, BCRYPT_USE_SYSTEM_PREFERRED_RNG),
    'BCryptGenRandom');
  Src := PByte(Plain);  // nil за празен вход е валидно: блок само с допълване (padding-only)

  // Запитване за размер: CBC допълването винаги добавя 1..16 байта, така че Need > Length(Plain)
  IVWork := IV;  // BCryptEncrypt придвижва IV буфера напред, докато го свързва верижно (chains)
  CngCheck(BCryptEncrypt(FKey, Src, Length(Plain), nil, @IVWork[0], 16,
    nil, 0, Need, BCRYPT_BLOCK_PADDING), 'BCryptEncrypt(size)');

  if ULONG(Length(FScratch)) < Need then
    SetLength(FScratch, Need);  // нараства няколко пъти, след което остава на място

  IVWork := IV;
  CngCheck(BCryptEncrypt(FKey, Src, Length(Plain), nil, @IVWork[0], 16,
    PByte(FScratch), Need, Written, BCRYPT_BLOCK_PADDING), 'BCryptEncrypt');

  // Оформление на AESV3: 16-байтовият IV, след това допълненият шифротекст
  Dest.WriteBuffer(IV[0], 16);
  Dest.WriteBuffer(FScratch[0], Written);
end;

Три детайла са носещи (load-bearing). Запитването за размер — първото извикване на BCryptEncrypt, с nil изходен буфер — връща допълнената дължина на шифротекста, никога не е равна на дължината на входа; допълването е детерминистично, така че можете сами да изчислите ((Len div 16) + 1) * 16 и да намалите наполовина броя на извикванията, но запитването е документираният договор (contract). Второ, BCryptEncrypt придвижва IV буфера на място (in place), докато свързва верижно (chains), така че във всяко извикване влиза работно копие (working copy), а непокътнатият (pristine) IV попада в изхода. Трето, FScratch само нараства, до най-големия обект във файла, след което цикълът не заделя (allocates) нищо

Колко струва повторното използване на манипулатора (handle), измерено

Файлът, който наложи това упражнение, беше 1.8 GB сканиран архив от заеми: 412 000 криптирани обекта, носещи 1710 MB полезен товар (payload), след като структурата от обикновен текст бъде извадена. Същата машина, същият файл, NVMe хранилище, една нишка (thread):

  • Настройка за всяко извикване (доставчикът е отворен и ключът е генериран вътре в помощника): фаза на криптиране 71.3 сек — 1710 MB ÷ 71.3 сек ≈ 24 MB/s
  • Издигнато състояние (State hoisted) (класът по-горе): 9.6 сек — 1710 MB ÷ 9.6 сек ≈ 178 MB/s

Разликата е 61.7 сек в 412 000 извиквания, или грубо 150 µs на извикване, прекарани в отваряне на доставчик (provider), задаване на режим на верижно свързване (chaining mode) и възстановяване на графика на ключовете (key schedule) за ключ, който никога не се е променил. Нищо от това не беше криптография. С AES-NI, CBC криптирането на големи буфери работи близо до 1.4 GB/s на едно ядро, така че самата AES аритметика представлява около 1.2 секунди от тези 9.6; по-голямата част от останалото са двата прехода BCryptEncrypt в потребителски режим (user-mode) на обект плюс генерирането на IV на обект. Пакетирането на IV-те (Batching the IVs) — едно извикване BCryptGenRandom, запълващо 4096 от тях — съкрати изпълнението до 8.9 сек. След това сте на минимума за обект (per-object floor) на API, а оставащият лост е паралелизмът: обектите /V 5 са независими под споделения ключ на файла, така че четири работни нишки (worker threads) с по един обект-ключ всяка свалиха фазата до 3.1 сек, преди писателят (writer) на изхода да се превърне в точката на сериализация

Пълно пренаписване срещу инкрементално (постепенно) запазване

Гранулярността също решава колко струва едно запазване. Добавянето на криптиране към съществуващ документ в обикновен текст пренаписва всеки обект по дефиниция: всеки поток и низ променя както съдържанието, така и дължината си, всяко отместване на кръстосана препратка (cross-reference offset) се премества и не съществува инкрементален път. Бюджетирайте го като пълно последователно пренаписване и записвайте във временен файл, който се преименува върху целевия, защото срив в средата на криптирането иначе оставя наполовина шифрован файл, който нито една парола няма да отвори

Обратната посока е евтината. След като файлът е криптиран, едно инкрементално обновяване добавя (appends) нови обекти, криптирани със същия ключ на файла, и оставя всеки оригинален байт непокътнат. Подпечатването (Stamping) на анотация за одобрение върху 2 GB криптиран архив струва килобайти добавен изход, а не 2 GB пренаписване. Изводът (corollary) за конвейера (pipeline): криптирайте веднъж, като последна стъпка от задачата, и оставете последващите докосвания да се возят на инкрементални запазвания. Ротация на парола, която също ротира ключа на файла, отново е пълно пренаписване — планирайте го като такова

Измерване на пропускателната способност без да се заблуждавате

Твърденията за пропускателната способност на криптирането често са грешни в числителя, знаменателя или и двете. Числителят трябва да бъде байтовете на полезния товар (payload bytes): сумата от дължините на потоците и низовете, действително прекарани през AES, след компресия, които писателят може да сумира в движение. Размерът на файла го преувеличава — архивът по-горе е 1.8 GB на диска, но само 1710 MB от него някога докосват шифъра. Знаменателят трябва да бъде само фазата на криптиране, оградена в скоби (bracketed) с TStopwatch от System.Diagnostics, като парсването, deflate компресията и дисковите I/O операции са извън скобите. Включете ги (Fold those in) и идентичен код за криптиране ще се измери няколко пъти по-бавно на файл, който просто се компресира по-зле. Цифрите по-горе са сравними именно защото и двете страни на делението са само за криптиране (encryption-only)

Нищо от това не трябва да бъде код, който притежавате. HotPDF обвива (wraps) същото инженерство зад свойствата на компонента — ActivateProtection, CryptKeyLength, UseAES256R6 — на правилната надморска височина (altitude) за интерактивни VCL приложения, с капаните при реда на присвояване, покрити в статията за HotPDF AES-256. За автоматизирани конвейери (unattended pipelines), PDFlibPas прилага AES-256 ревизия 6 към съществуващи файлове с едно-единствено извикване на EncryptFile при Strength 4 и след това проверява какво е попаднало на диска, работен процес, разгледан в статията за одит на криптирането с PDFlibPas

Пътищата за криптиране, описани тук, се доставят в компонента HotPDF за Delphi и C++Builder и в библиотеката PDFlibPas; и двете продуктови страници носят пълната референция за криптиране