مقاله فنی

رمزگذاری پرسرعت AES-256 برای PDF های عظیم

رمزگذاری یک PDF با اندازه 2 GB در نگاه اول یک مسئله streaming به نظر می رسد: فایل را باز کن، دو گیگابایت را از AES-256 عبور بده و خروجی را بنویس. این مدل ذهنی اشتباه است، آن هم به شکلی که کل بودجه performance را تعیین می کند. ISO 32000-1 §7.6 granularity رمزگذاری PDF را در سطح object منفرد تعریف می کند؛ هر stream و هر string جداگانه رمز می شود، هر کدام با initialization vector و padding مخصوص خود. یک آرشیو اسکن شده 2 GB با 500,000 object در واقع 500,000 عملیات کوچک CBC است، نه یک عبور بلند، و در این مقیاس هزینه ثابت اطراف هر عملیات از خود حساب AES مهم تر می شود

این مقاله درباره همین هزینه ثابت است: اینکه وقتی کد Delphi روی اسناد خیلی بزرگ AES-256 اعمال می کند زمان دقیقاً کجا می رود و چطور می شود آن را پس گرفت. برای سمت setup یعنی password ها، permission flag ها و تصمیم سازگاری revision 5 در برابر 6، به مقاله همراه درباره پیکربندی رمزگذاری AES-256 در HotPDF مراجعه کنید؛ آن بخش اینجا تکرار نمی شود

نیم میلیون عملیات CBC، نه یک عبور واحد

اسکلت فایل به صورت plaintext باقی می ماند. جدول های cross-reference، شماره object ها، کلیدهای dictionary و page tree رمز نمی شوند و همین باعث می شود reader پیش از اعتبارسنجی password بتواند object ها را پیدا کند. چیزی که استاندارد رمز می کند محتوا است: stream data مثل توصیف صفحه، تصویر، فونت و attachment، و همین طور string هایی مانند metadata value یا متن annotation. زیر AES-256 crypt filter هر کدام جداگانه پردازش می شوند: یک IV تصادفی تازه 16 بایتی، CBC روی بایت ها، block padding تا مرز 16 بایت، و نوشتن IV به صورت clear پیش از ciphertext

از اینجا دو نتیجه بیرون می آید. اول اینکه ciphertext همیشه از plaintext طولانی تر است: IV شانزده بایت اضافه می کند و padding هم 1 تا 16 بایت دیگر، پس یک string صد بایتی روی دیسک 128 بایت اشغال می کند و حتی یک stream خالی هم 32 بایت تولید می کند. کدی که output buffer را برابر طول ورودی بگیرد، یا فقط همان تعداد بایتی را که خوانده بازنویسی کند، فایل هایی تولید می کند که در block آخر هر object از decrypt شدن باز می مانند. دوم اینکه هزینه فقط با byte count حرکت نمی کند، با object count هم حرکت می کند. یک آرشیو اسکن شده بیشتر بایت هایش را در چند image stream بزرگ متمرکز می کند اما هم زمان صدها هزار stream کوتاه و string کوچک دارد که در آن ها هزینه هر عملیات، نه خود AES، قبض نهایی را می سازد

تنها رحمت در طراحی AES-256 سمت key handling است. security handler های تا revision 4 برای هر object یک key مجزا از روی file key به همراه object number و generation number مشتق می کردند و در نتیجه هر بار key schedule تازه لازم بود. scheme های /V 5 این مشتق سازی per-object را حذف کردند: یک file key تصادفی 256 بیتی تمام object های سند را رمز می کند. همین واقعیت بهینه سازی های بعدی را مجاز می کند، یعنی state پرهزینه رمزنگاری را می شود یک بار برای هر فایل ساخت نه یک بار برای هر object

دیکشنری R6 /Encrypt: یک باز کردن کند، object های ارزان

یک سند revision 6 scheme خود را در /Encrypt dictionary مربوط به trailer اعلام می کند و entry های مهم آن در چند خط جا می شوند:

uses
  System.Classes, System.SysUtils;

const
  BUFFER_SIZE = 65536; // 64KB chunks

// This represents your AES-256 encryption routine
procedure EncryptStreamChunk(const InBuffer; var OutBuffer; BytesRead: Integer; const Key: TBytes; const IV: TBytes);
begin
  // Call to Windows CNG (BCryptEncrypt) or OpenSSL (EVP_EncryptUpdate)
  // ...
end;

procedure EncryptLargePDFStream(InputStream, OutputStream: TStream; const Key, IV: TBytes);
var
  InBuffer, OutBuffer: array of Byte;
  BytesRead: Integer;
begin
  SetLength(InBuffer, BUFFER_SIZE);
  // AES CBC requires padding, so the output buffer must be slightly larger
  SetLength(OutBuffer, BUFFER_SIZE + 16); 

  InputStream.Position := 0;
  OutputStream.Position := 0;

  repeat
    BytesRead := InputStream.Read(InBuffer[0], BUFFER_SIZE);
    if BytesRead > 0 then
    begin
      EncryptStreamChunk(InBuffer[0], OutBuffer[0], BytesRead, Key, IV);
      // Write the encrypted cipher text directly to disk
      OutputStream.Write(OutBuffer[0], BytesRead); // Note: padding logic omitted for brevity
    end;
  until BytesRead < BUFFER_SIZE;
end;

بهینه سازی backend رمزنگاری

توسعه دهندگان Delphi برای backend مربوط به AES چند انتخاب دارند:

  • پیاده سازی بومی Delphi: استقرار آسان دارد، اما معمولاً کندتر است چون کاملاً در نرم افزار اجرا می شود
  • Windows CNG (BCrypt): بسیار بهینه است و می تواند از شتاب سخت افزاری، مثل دستورهای AES-NI روی CPU های مدرن Intel و AMD، استفاده کند
  • OpenSSL (libcrypto): استاندارد صنعتی است، سرعت بسیار بالایی دارد، اما نیازمند همراه کردن DLL خارجی است

برای برنامه های server با throughput بالا، شتاب سخت افزاری AES-NI عملاً ضروری است. وقتی در Delphi از Windows CNG استفاده می کنید، map کردن تابع BCryptEncrypt اجازه می دهد برنامه کار سنگین را به واحدهای اختصاصی رمزنگاری داخل CPU بسپارد و در نتیجه سربار encryption را تقریباً تا نزدیک صفر پایین بیاورد

/V 5 معماری key 256 بیتی را انتخاب می کند و /R 6 handshake سخت شده ISO 32000-2 را. /CF crypt filter نام دار را تعریف می کند، جایی که /AESV3 یعنی AES-256 در حالت CBC با IV پیشوند شده، و /StmF و /StrF این filter را به stream ها و string ها نسبت می دهند. /O، /U، /OE و /UE مواد لازم برای password verification و key wrapping را نگه می دارند، و /Perms یک کپی AES-encrypted از permission bit ها را حمل می کند تا editor خصمانه نتواند بی صدا /P را عوض کند

ساختار هزینه داخل /OE و /UE پنهان شده است. باز کردن file key از روی آن ها Algorithm 2.B را اجرا می کند، یعنی یک key-derivation function تکرارشونده که round های SHA-256، SHA-384 و SHA-512 را زنجیر می کند، حداقل 64 دور با یک stopping rule وابسته به داده، و عمداً کند طراحی شده تا حدس زدن password گران بماند. این هزینه یک بار هنگام تولید فایل توسط writer پرداخت می شود و یک بار هنگام باز کردن فایل توسط reader، هر بار در حد چند millisecond تک رقمی. در فایل نیم میلیون objectی، KDF فقط نویز است و اگر save کند باشد، متهم Algorithm 2.B نیست؛ متهم loop مربوط به هر object است

handle کلید را reuse کن، scratch buffer را هم reuse کن

پیاده سازی naive یک utility function مرتب است: helperی به نام EncryptAes256Cbc که provider مربوط به Windows CNG را باز می کند، CBC را انتخاب می کند، key object را می سازد، یک buffer را رمز می کند و بعد همه چیز را جمع می کند. از نظر correctness و unit-test خیلی تمیز است و داخل یک loop با 500,000 تکرار فاجعه کامل. مستندات Microsoft روی BCryptOpenAlgorithmProvider برچسب expensive می زند و caching handle را توصیه می کند، و BCryptGenerateSymmetricKey هم کل AES key schedule را اجرا کرده و state provider را allocate می کند، یعنی اتلاف محض وقتی کلید در طول سند عوض نمی شود

RTL در Delphi هیچ bcrypt import unitی همراه ندارد، پس entry point ها را مستقیم declare کنید. کلاس زیر تمام state رمزنگاری را یک بار می سازد و بعد هر تعداد object را بدون 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 key-object workspace, allocated once
    FScratch: TBytes;    // ciphertext scratch, grows and then stays
  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);
  // The AES key schedule is built once here and reused for every object
  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
  // Fresh random IV per object; it travels in the clear ahead of the data
  CngCheck(BCryptGenRandom(nil, @IV[0], 16, BCRYPT_USE_SYSTEM_PREFERRED_RNG),
    'BCryptGenRandom');
  Src := PByte(Plain);  // nil for an empty input is valid: padding-only block

  // Size query: CBC padding always adds 1..16 bytes, so Need > Length(Plain)
  IVWork := IV;  // BCryptEncrypt advances the IV buffer while it 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);  // grows a handful of times, then stays put

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

  // AESV3 layout: the 16-byte IV, then the padded ciphertext
  Dest.WriteBuffer(IV[0], 16);
  Dest.WriteBuffer(FScratch[0], Written);
end;

سه جزئیات در اینجا بار اصلی را حمل می کنند. query مربوط به اندازه، یعنی همان فراخوانی اول BCryptEncrypt با output buffer تهی، طول ciphertext پد شده را برمی گرداند و این مقدار هرگز برابر طول ورودی نیست. padding قطعی است، بنابراین می توانید خودتان ((Len div 16) + 1) * 16 را حساب کنید و تعداد call ها را نصف کنید، اما query همان قرارداد مستند شده است. دوم اینکه BCryptEncrypt buffer مربوط به IV را هنگام chaining درجا جلو می برد، پس یک working copy وارد هر call می شود و IV دست نخورده در output می نشیند. سوم اینکه FScratch فقط بزرگ می شود، تا اندازه بزرگ ترین object فایل، و بعد از آن loop دیگر هیچ allocateی انجام نمی دهد

ارزش reuse کردن handle در عمل چقدر است

فایلی که این تمرین را مجبور کرد انجام دهیم یک آرشیو اسکن شده وام با اندازه 1.8 GB بود: 412,000 object رمز شده که بعد از کنار گذاشتن ساختار plaintext، 1,710 MB payload حمل می کردند. همان ماشین، همان فایل، ذخیره ساز NVMe و یک thread:

  • راه اندازی در هر فراخوانی (provider داخل helper باز می شود و key همان جا تولید می شود): phase رمزگذاری 71.3 s — 1,710 MB ÷ 71.3 s ≈ 24 MB/s
  • بالا کشیدن state (همان کلاس بالا): 9.6 s — 1,710 MB ÷ 9.6 s ≈ 178 MB/s

تفاوت برابر با 61.7 s در طول 412,000 call بود، یعنی حدود 150 µs در هر call که صرف باز کردن provider، تنظیم chaining mode و بازسازی key schedule برای کلیدی می شد که هیچ وقت عوض نشده بود. هیچ کدام از این ها خود رمزنگاری نبود. با AES-NI، رمزگذاری CBC روی buffer های بزرگ نزدیک 1.4 GB/s روی یک core اجرا می شود، بنابراین حساب AES خودش فقط حدود 1.2 s از آن 9.6 را می سازد؛ بیشتر بقیه زمان صرف دو گذار user-mode مربوط به BCryptEncrypt برای هر object و تولید IV برای هر object می شود. batch کردن IV ها، یعنی یک call به BCryptGenRandom که 4,096 IV را پر کند، زمان را به 8.9 s رساند. بعد از آن به کف per-object خود API رسیده اید و lever باقی مانده parallelism است: object های /V 5 زیر file key مشترک مستقل هستند، پس چهار worker thread با یک key object برای هر کدام این phase را تا 3.1 s پایین آوردند، تا قبل از اینکه output writer به نقطه serialization تبدیل شود

بازنویسی کامل در برابر incremental save

granularity همچنین تعیین می کند save چقدر هزینه داشته باشد. اضافه کردن encryption به یک سند plaintext موجود، بنا به تعریف هر object را بازنویسی می کند: هر stream و string هم محتوا و هم طول خود را عوض می کند، هر cross-reference offset جابه جا می شود و هیچ مسیر incremental وجود ندارد. بودجه آن را مثل یک full sequential rewrite ببندید و در فایل موقت بنویسید که بعداً روی هدف rename شود، چون crash در میانه encryption در غیر این صورت یک فایل نیمه رمز شده جا می گذارد که هیچ passwordی آن را باز نمی کند

جهت معکوس ارزان است. وقتی یک فایل رمز شد، incremental update object های جدیدی را با همان file key append می کند و هر بایت اصلی را دست نخورده می گذارد. اضافه کردن یک approval annotation به آرشیو رمز شده 2 GB، فقط چند kilobyte خروجی append شده هزینه دارد نه یک بازنویسی 2 GB. نتیجه pipeline این است: یک بار و در آخرین مرحله job سند را encrypt کن و بگذار دست کاری های بعدی روی incremental save سوار شوند. password rotation که هم زمان file key را هم عوض کند دوباره full rewrite است و باید مثل همان برنامه ریزی شود

سنجش throughput بدون فریب دادن خودتان

ادعاهای throughput در encryption معمولاً در numerator، denominator یا هر دو اشتباه هستند. numerator باید payload byte ها باشد: جمع طول stream ها و string هایی که واقعاً بعد از compression از AES عبور کرده اند و writer می تواند در حین کار جمعشان بزند. file size آن را بیش از واقعیت نشان می دهد؛ آرشیو بالا روی دیسک 1.8 GB است اما فقط 1,710 MB آن واقعاً به cipher می رسد. denominator باید فقط phase مربوط به encryption باشد که با TStopwatch از System.Diagnostics دور آن خط می کشید و parsing، deflate و disk I/O را بیرون آن نگه می دارید. اگر آن ها را هم داخل کنید همان کد encryption روی فایلی که فقط compression بدتری دارد چند برابر کندتر اندازه گیری می شود. اعداد بالا دقیقاً چون هر دو طرف کسر فقط مربوط به encryption هستند با هم قابل مقایسه اند

هیچ کدام از این ها لازم نیست حتماً کدی باشد که خودتان بنویسید. HotPDF همین مهندسی را پشت property های component مثل ActivateProtection، CryptKeyLength و UseAES256R6 پنهان می کند، در ارتفاعی مناسب برای برنامه های تعاملی VCL، و دام های ترتیب assignment هم در مقاله HotPDF درباره AES-256 توضیح داده شده است. برای pipeline های بدون ناظر، PDFlibPas روی فایل موجود فقط با یک call به EncryptFile در Strength 4 رمزگذاری AES-256 revision 6 را اعمال می کند و بعد هم چیزی را که واقعاً روی دیسک نشسته verify می کند، همان workflowی که در مقاله ممیزی رمزگذاری PDFlibPas قدم به قدم آمده است

مسیرهای encryption که اینجا توضیح داده شدند در HotPDF Component برای Delphi و C++Builder و همین طور در PDFlibPas library عرضه می شوند و هر دو صفحه محصول مرجع کامل encryption را در اختیار می گذارند