مقال تقني

تشفير PDF عالي السرعة بـ AES-256 للمستندات الضخمة

يبدو تشفير ملف PDF بحجم 2 جيجابايت وكأنه مشكلة تدفق (streaming problem): افتح الملف، وادفع اثنين جيجابايت عبر AES-256، واكتب النتيجة. هذا النموذج العقلي (mental model) خاطئ بطريقة تحدد ميزانية الأداء (performance budget) بأكملها. يحدد ISO 32000-1 §7.6 دقة تشفير PDF عند الكائن الفردي - يتم تشفير كل تيار وكل سلسلة بشكل منفصل، كل منها بمتجه التهيئة (initialization vector) الخاص به وحشوته (padding) الخاصة. الأرشيف الممسوح ضوئيًا (scanned archive) بحجم 2 جيجابايت مع 500,000 كائن هو 500,000 عملية CBC صغيرة، وليس تمريرة واحدة طويلة، وفي هذا المقياس، التكلفة الثابتة حول كل عملية تهم أكثر من حسابيات AES داخلها

يتعلق هذا المقال بتلك التكلفة الثابتة: أين يذهب الوقت عندما يطبق كود Delphi تقنية AES-256 على المستندات الكبيرة جدًا، وكيف يمكن استعادته. بالنسبة للجانب الخاص بالإعداد - كلمات المرور، وعلامات الأذونات (permission flags)، واستدعاء التوافق الخاص بالمراجعة 5 مقابل المراجعة 6 (revision 5 versus 6 compatibility call) - راجع المقال المصاحب حول تكوين تشفير AES-256 في HotPDF؛ لا يتكرر أي من ذلك هنا

نصف مليون عملية CBC، وليس تمريرة واحدة

يبقى هيكل (skeleton) الملف في نص عادي (plaintext). الجداول المرجعية التبادلية (Cross-reference tables)، وأرقام الكائنات، ومفاتيح القاموس، وشجرة الصفحة: لا يتم تشفير أي منها، وهو ما يتيح للقارئ تحديد موقع الكائنات قبل أن يتحقق من صحة كلمة المرور. ما يشفره المعيار هو المحتوى - بيانات التيار مثل أوصاف الصفحات (page descriptions)، والصور، والخطوط، والمرفقات، بالإضافة إلى السلاسل مثل قيم البيانات الوصفية ونص التعليق التوضيحي. تحت عامل تصفية التشفير (crypt filter) AES-256 تتم معالجة كل واحد بمفرده: IV (متجه تهيئة) عشوائي جديد من 16 بايت، و CBC على البايتات، وحشو كتلة (block padding) إلى حدود 16 بايت، وكتابة IV في نص واضح (clear) قبل النص المشفر (ciphertext)

ينتج عن ذلك نتيجتان. أولاً، النص المشفر أطول دائمًا من النص العادي: يضيف IV 16 بايت وتضيف الحشوة (padding) من 1 إلى 16 بايت أخرى، لذا فإن سلسلة مكونة من 100 بايت تشغل 128 بايت على القرص ولا يزال التيار الفارغ ينتج 32. الكود الذي يقوم بتغيير حجم المخزن المؤقت (buffer) للإخراج إلى طول الإدخال، أو يكتب للخلف فقط نفس عدد البايتات التي قرأها، ينتج ملفات تفشل في فك تشفيرها عند الكتلة الأخيرة (last block) من كل كائن. ثانيًا، تتتبع التكلفة عدد الكائنات (object count)، وليس فقط عدد البايتات. يركز الأرشيف الممسوح ضوئيًا بايتاته في عدد قليل من تيارات الصور الكبيرة، ولكنه يحمل مئات الآلاف من التيارات القصيرة والسلاسل الصغيرة حيث التكاليف العامة لكل عملية (per-operation overhead)، وليس AES، هي الفاتورة (bill)

الرحمة الوحيدة في تصميم AES-256 هي معالجة المفاتيح (key handling). اشتقت معالجات الأمان (Security handlers) حتى المراجعة 4 مفتاحًا مميزًا لكل كائن عن طريق تجزئة (hashing) مفتاح الملف مع رقم الكائن ورقم الإنشاء (generation numbers)، مما يفرض جدول مفاتيح (key schedule) جديدًا في كل مرة. أسقطت مخططات /V 5 الاشتقاق لكل كائن (per-object derivation): مفتاح ملف واحد عشوائي 256 بت يشفر كل كائن في المستند. تتيح هذه الحقيقة كل تحسين أدناه - يمكن بناء حالة التشفير (cryptographic state) باهظة الثمن مرة واحدة لكل ملف، وليس مرة واحدة لكل كائن

قاموس /Encrypt الخاص بـ R6: فتح بطيء واحد، وكائنات رخيصة

يعلن مستند من المراجعة 6 (revision 6) عن مخططه (scheme) في قاموس /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 المصافحة (handshake) المشددة لـ ISO 32000-2. يحدد /CF عامل تصفية التشفير (crypt filter) المسمى - /AESV3 يعني AES-256 في وضع CBC مع IV المضاف قبله (prepended IV) - وتقوم /StmF و /StrF بتعيين هذا الفلتر للتيارات والسلاسل على التوالي. تحتوي /O و /U و /OE و /UE على مادة التحقق من كلمة المرور (password verification) ومادة تغليف المفاتيح (key-wrapping material)، ويحمل /Perms نسخة مشفرة بـ AES من بتات الأذونات بحيث لا يتمكن محرر معاد (hostile editor) من قلب (flip) /P بصمت

يختبئ هيكل التكلفة (cost structure) في /OE و /UE. يؤدي فك تغليف (Unwrapping) مفتاح الملف منها إلى تشغيل Algorithm 2.B، وهي وظيفة اشتقاق مفتاح متكررة (iterated key-derivation function) تربط جولات (rounds) SHA-256 و SHA-384 و SHA-512 - 64 منها على الأقل، مع قاعدة توقف تعتمد على البيانات (data-dependent stopping rule) - تم بناؤها بطيئة عن عمد بحيث يظل تخمين كلمة المرور مكلفًا. يتم دفع هذا السعر مرة واحدة عندما ينتج الكاتب الملف ومرة أخرى عندما يفتحه القارئ، بضعة مللي ثانية في كل مرة (single-digit milliseconds). في ملف مكون من نصف مليون كائن، KDF هو مجرد ضجيج (noise)، وإذا كان الحفظ بطيئًا، فإن Algorithm 2.B ليس هو المشتبه به؛ بل هي حلقة معالجة الكائنات (per-object loop)

أعد استخدام مقبض المفتاح (key handle)، وأعد استخدام المخزن المؤقت المؤقت (scratch buffer)

التنفيذ الساذج (naive implementation) هو وظيفة مساعدة مرتبة: مساعد EncryptAes256Cbc يفتح مزود CNG الخاص بـ Windows، ويحدد CBC، وينشئ كائن المفتاح (key object)، ويشفر مخزنًا مؤقتًا (buffer) واحدًا، ثم يهدم كل شيء (tears everything down). كود صحيح، قابل للاختبار كوحدة (unit-testable)، وكارثي داخل حلقة تكرار مكونة من 500,000 تكرار. تشير وثائق Microsoft إلى أن BCryptOpenAlgorithmProvider باهظ الثمن وتوصي بالتخزين المؤقت للمقبض (caching the handle)، ويشغل BCryptGenerateSymmetricKey جدول مفاتيح AES بالكامل ويخصص حالة المزود (provider state) - وهو إهدار خالص عندما لا يتغير المفتاح أبدًا عبر المستند

لا تشحن RTL الخاصة بـ Delphi وحدة استيراد bcrypt، لذا قم بالتصريح عن نقاط الإدخال (entry points) مباشرة. تبني الفئة أدناه كل حالة التشفير مرة واحدة ثم تشفر أي عدد من الكائنات بدون تخصيص للحالة الثابتة (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 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;

هناك ثلاثة تفاصيل تحمل الحمل (load-bearing). استعلام الحجم (size query) - استدعاء BCryptEncrypt الأول، مع مخزن إخراج مؤقت (output buffer) بقيمة nil - يُرجع طول النص المشفر المبطن (padded ciphertext length)، ولا يساوي أبدًا طول الإدخال؛ الحشو (padding) حتمي (deterministic)، لذا يمكنك حساب ((Len div 16) + 1) * 16 بنفسك وتخفيض عدد الاستدعاءات إلى النصف، لكن الاستعلام هو العقد الموثق (documented contract). ثانيًا، يقدم BCryptEncrypt مخزن IV المؤقت في مكانه أثناء تسلسله (chains)، لذلك تدخل نسخة عاملة (working copy) في كل استدعاء ويهبط IV الأصلي (pristine) في الإخراج. ثالثًا، FScratch ينمو فقط، حتى أكبر كائن في الملف، وبعد ذلك لا تخصص الحلقة (loop) أي شيء

قيمة إعادة استخدام المقبض، مقاسة

الملف الذي فرض هذا التمرين (exercise) كان عبارة عن أرشيف قروض ممسوح ضوئيًا بحجم 1.8 جيجابايت: 412,000 كائن مشفر يحمل 1,710 ميجابايت من الحمولة (payload) بمجرد طرح بنية النص العادي (plaintext structure). نفس الجهاز، ونفس الملف، ووحدة تخزين NVMe، وخيط واحد (one thread):

  • إعداد كل استدعاء (Per-call setup) (فتح المزود وتوليد المفتاح داخل المساعد): مرحلة التشفير 71.3 ثانية - 1,710 ميجابايت ÷ 71.3 ثانية ≈ 24 ميجابايت/ثانية
  • رفع الحالة (State hoisted) (الفئة أعلاه): 9.6 ثانية - 1,710 ميجابايت ÷ 9.6 ثانية ≈ 178 ميجابايت/ثانية

الفرق هو 61.7 ثانية عبر 412,000 استدعاء، أو حوالي 150 ميكروثانية (µs) لكل استدعاء تُنفق في فتح مزود، وتعيين وضع تسلسل (chaining mode)، وإعادة بناء جدول مفاتيح لمفتاح لم يتغير أبدًا. لا شيء من ذلك كان تشفيرًا. باستخدام AES-NI، يعمل تشفير CBC للمخازن المؤقتة (buffers) الكبيرة بالقرب من 1.4 جيجابايت/ثانية على نواة واحدة، لذا فإن الحساب الخاص بـ AES نفسه يمثل حوالي 1.2 ثانية من الـ 9.6؛ معظم الباقي هو انتقالا (transitions) BCryptEncrypt في وضع المستخدم (user-mode) لكل كائن بالإضافة إلى توليد IV لكل كائن. إن تجميع (Batching) الـ IVs - استدعاء BCryptGenRandom واحد يملأ 4,096 منها - قلص التشغيل إلى 8.9 ثانية. بعد ذلك، أنت في الحد الأدنى لكل كائن (per-object floor) لواجهة برمجة التطبيقات (API)، والرافعة المتبقية (remaining lever) هي التوازي (parallelism): كائنات /V 5 مستقلة تحت مفتاح الملف المشترك، لذا فإن أربعة خيوط عاملة (worker threads) مع كائن مفتاح واحد لكل منها أخذت المرحلة إلى 3.1 ثانية قبل أن يصبح كاتب الإخراج (output writer) نقطة التسلسل (serialization point)

إعادة الكتابة الكاملة مقابل الحفظ التزايدي (incremental save)

يحدد مستوى الدقة (Granularity) أيضًا تكلفة الحفظ. إضافة التشفير إلى مستند نص عادي موجود يعيد كتابة كل كائن بحكم التعريف: يتغير محتوى وطول كل تيار وكل سلسلة، وتتحرك كل إزاحة مرجعية تبادلية (cross-reference offset)، ولا يوجد مسار تزايدي (incremental path). قم بوضع ميزانية لها كعملية إعادة كتابة تسلسلية (sequential rewrite) كاملة، واكتب في ملف مؤقت تتم إعادة تسميته فوق الهدف، لأن الانهيار (crash) في منتصف التشفير يترك بخلاف ذلك ملفًا نصف مشفر (half-ciphered file) لن تفتحه أي كلمة مرور

الاتجاه المعاكس هو الاتجاه الرخيص. بمجرد تشفير الملف، يُلحق تحديث تزايدي (incremental update) كائنات جديدة مشفرة بنفس مفتاح الملف ويترك كل بايت أصلي دون مساس. إن ختم تعليق توضيحي للموافقة (approval annotation) على أرشيف مشفر بحجم 2 جيجابايت يكلف كيلوبايتات من المخرجات الملحقة (appended output)، وليس إعادة كتابة بحجم 2 جيجابايت. نتيجة المسار: قم بالتشفير مرة واحدة، كخطوة أخيرة في المهمة، ودع اللمسات (touches) اللاحقة تعتمد على عمليات الحفظ التزايدية (incremental saves). يعد تدوير (rotation) كلمة المرور الذي يؤدي أيضًا إلى تدوير مفتاح الملف إعادة كتابة كاملة مرة أخرى - قم بجدولتها على هذا النحو

قياس الإنتاجية (throughput) دون أن تخدع نفسك

تميل مطالبات إنتاجية (throughput) التشفير إلى أن تكون خاطئة في البسط (numerator)، أو المقام (denominator)، أو كليهما. يجب أن يكون البسط هو بايتات الحمولة (payload bytes): مجموع أطوال التيارات والسلاسل المدفوعة فعليًا عبر AES، بعد الضغط، والتي يمكن للكاتب جمعها (total) أثناء سيره. حجم الملف يبالغ في تقديره (overstates it) - الأرشيف أعلاه يبلغ حجمه 1.8 جيجابايت على القرص، ولكن 1,710 ميجابايت فقط منه يلامس التشفير على الإطلاق. يجب أن يكون المقام هو مرحلة التشفير وحدها، موضوعة بين أقواس (bracketed) بـ TStopwatch من System.Diagnostics، مع تحليل (parsing) و deflate وإدخال/إخراج القرص (disk I/O) خارج الأقواس. اطوِ هذه (Fold those in) في نفس كود التشفير وسيقيس أبطأ عدة مرات في ملف ضغطه أسوأ (compresses worse) ببساطة. الأرقام أعلاه قابلة للمقارنة على وجه التحديد لأن كلا جانبي القسمة هما تشفير فقط

لا يجب أن يكون أي من هذا كودًا تملكه. يقوم HotPDF بتغليف نفس الهندسة خلف خصائص المكون (component properties) - ActivateProtection و CryptKeyLength و UseAES256R6 - بالارتفاع المناسب (right altitude) لتطبيقات VCL التفاعلية، مع تغطية مخاطر (pitfalls) ترتيب التعيين في مقال HotPDF AES-256. بالنسبة للمسارات غير المراقبة (unattended pipelines)، يقوم PDFlibPas بتطبيق AES-256 المراجعة 6 على الملفات الحالية في استدعاء EncryptFile واحد بالقوة 4 (Strength 4) ويتحقق بعد ذلك مما هبط على القرص، وهو سير عمل (workflow) يتم المرور عليه في مقال تدقيق تشفير PDFlibPas

تشحن مسارات التشفير الموضحة هنا في مكون HotPDF لـ Delphi و C++Builder وفي مكتبة PDFlibPas؛ تحمل كلتا صفحتي المنتج مرجع التشفير (encryption reference) الكامل