مقاله فنی

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

رمزگذاری یک PDF دو گیگابایتی مثل یک مسئلهٔ جریانی به نظر می‌رسد: فایل را باز کن، دو گیگابایت را از AES-256 عبور بده، نتیجه را بنویس. این مدل ذهنی به شکلی نادرست است که کل بودجهٔ کارایی را تعیین می‌کند. استاندارد ISO 32000-1 §7.6 دانه‌بندی رمزگذاری PDF را روی تک‌تک اشیا می‌گذارد؛ هر استریم و هر رشته جداگانه رمز می‌شود، هر کدام با بردار مقداردهی اولیهٔ خود و padding خود. یک بایگانی اسکن‌شدهٔ دو گیگابایتی با 500,000 شیء یعنی 500,000 عملیات کوچک CBC، نه یک گذر بلند، و در این مقیاس هزینهٔ ثابت پیرامون هر عملیات بیش از حساب‌وکتاب AES درون آن اهمیت دارد

این مقاله دربارهٔ همان هزینهٔ ثابت است: وقتی کد دلفی AES-256 را روی اسناد بسیار بزرگ اعمال می‌کند زمان کجا می‌رود، و چطور می‌توان آن را پس گرفت. برای سمت پیکربندی — رمزهای عبور، پرچم‌های مجوز، انتخاب میان نسخهٔ 5 و 6 از نظر سازگاری — مقالهٔ همراه دربارهٔ پیکربندی رمزگذاری AES-256 در HotPDF را ببینید؛ هیچ‌کدام از آن‌ها اینجا تکرار نمی‌شود

نیم میلیون عملیات CBC، نه یک گذر

اسکلت فایل به شکل متن آشکار باقی می‌ماند. جدول‌های ارجاع متقاطع، شماره‌های شیء، کلیدهای دیکشنری، درخت صفحه‌ها: هیچ‌یک رمز نمی‌شوند، و به همین دلیل است که یک خواننده می‌تواند پیش از اعتبارسنجی رمز عبور جای اشیا را پیدا کند. آنچه استاندارد رمز می‌کند محتواست — دادهٔ استریم مانند توصیف صفحه‌ها، تصویرها، فونت‌ها و پیوست‌ها، به‌علاوهٔ رشته‌ها مانند مقادیر فراداده و متن حاشیه‌نویسی. زیر فیلتر رمزنگاری AES-256 هر کدام به تنهایی پردازش می‌شود: یک IV تصادفی تازهٔ 16 بایتی، CBC روی بایت‌ها، padding بلوکی تا مرز 16 بایت، و نوشتن IV به شکل آشکار پیش از متن رمزشده

نمودار PDF از دانه‌بندی رمزگذاری AES-256 که در آن یک کلید فایل مشترک 256 بیتی نیم میلیون استریم و رشتهٔ محتوا را جداگانه مهر می‌کند در حالی که اسکلت ساختاری متن آشکار می‌ماند
اسکلت خواندنی می‌ماند در حالی که هر استریم و رشته جداگانه مهر می‌شود — هر قلم یک IV تازهٔ 16 بایتی، زنجیره‌سازی CBC و padding بلوکی زیر یک کلید فایل مشترک 256 بیتی حمل می‌کند

دو پیامد از این وضع برمی‌آید. نخست، متن رمزشده همیشه بلندتر از متن آشکار است: IV شانزده بایت می‌افزاید و padding یک تا شانزده بایت دیگر، پس یک رشتهٔ 100 بایتی روی دیسک 128 بایت می‌گیرد و یک استریم خالی هم 32 بایت تولید می‌کند. کدی که بافر خروجی را به اندازهٔ طول ورودی می‌گیرد، یا فقط همان تعداد بایتی را که خوانده بازمی‌نویسد، فایل‌هایی می‌سازد که در آخرین بلوک هر شیء رمزگشایی نمی‌شوند. دوم، هزینه با شمار اشیا پیش می‌رود، نه فقط با شمار بایت‌ها. یک بایگانی اسکن‌شده بایت‌هایش را در چند استریم تصویری بزرگ متمرکز می‌کند، اما صدها هزار استریم کوتاه و رشتهٔ کوچک حمل می‌کند که در آن‌ها سربار هر عملیات، نه AES، صورت‌حساب را می‌نویسد

تنها رحم موجود در طراحی AES-256 نحوهٔ برخورد با کلید است. مدیران امنیتی تا بازنگری 4 برای هر شیء کلیدی جداگانه مشتق می‌کردند و کلید فایل را با شمارهٔ شیء و شمارهٔ نسل درهم می‌کردند، که هر بار زمان‌بندی کلید تازه‌ای را تحمیل می‌کرد. طرح‌های /V 5 اشتقاق به‌ازای شیء را کنار گذاشتند: یک کلید فایل تصادفی 256 بیتی همهٔ اشیای سند را رمز می‌کند. همین واقعیت مجوز همهٔ بهینه‌سازی‌های زیر است — وضعیت پرهزینهٔ رمزنگاری را می‌توان یک بار برای هر فایل ساخت، نه یک بار برای هر شیء

دیکشنری ‎/Encrypt در 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 دست‌دادن سخت‌شدهٔ ISO 32000-2 را. کلید /CF فیلتر رمزنگاری نام‌دار را تعریف می‌کند — /AESV3 یعنی AES-256 در حالت CBC با IV پیش‌درآمد — و /StmF و /StrF آن فیلتر را به‌ترتیب به استریم‌ها و رشته‌ها نسبت می‌دهند. کلیدهای /O، /U، /OE و /UE مادهٔ راستی‌آزمایی رمز عبور و بسته‌بندی کلید را نگه می‌دارند، و /Perms یک نسخهٔ رمزشده با AES از بیت‌های مجوز را حمل می‌کند تا ویرایشگری خصمانه نتواند بی‌صدا /P را وارونه کند

ساختار هزینه در /OE و /UE پنهان است. بازکردن کلید فایل از دل آن‌ها الگوریتم 2.B را اجرا می‌کند، یک تابع اشتقاق کلید تکرارشونده که دورهای SHA-256، SHA-384 و SHA-512 را زنجیر می‌کند — دست‌کم 64 دور، با قاعدهٔ توقفی وابسته به داده — و عمداً کند ساخته شده تا حدس زدن رمز عبور گران بماند. این بها یک بار هنگام تولید فایل توسط نویسنده و یک بار هنگام بازکردن آن توسط خواننده پرداخت می‌شود، هر بار چند میلی‌ثانیهٔ تک‌رقمی. روی فایلی با نیم میلیون شیء، KDF نویز است، و اگر ذخیره‌سازی کند باشد، الگوریتم 2.B متهم نیست؛ حلقهٔ به‌ازای شیء متهم است

هندل کلید را بازاستفاده کنید، بافر موقت را هم

پیاده‌سازی ساده‌لوحانه یک تابع کمکی مرتب است: یک EncryptAes256Cbc که ارائه‌دهندهٔ CNG ویندوز را باز می‌کند، CBC را برمی‌گزیند، شیء کلید را می‌سازد، یک بافر را رمز می‌کند و همه‌چیز را برمی‌چیند. درست، قابل آزمون واحد و فاجعه‌بار درون حلقه‌ای با 500,000 تکرار. مستندات مایکروسافت BCryptOpenAlgorithmProvider را گران علامت می‌زند و کش کردن هندل را توصیه می‌کند، و BCryptGenerateSymmetricKey زمان‌بندی کامل کلید AES را اجرا و وضعیت ارائه‌دهنده را تخصیص می‌دهد — اتلاف محض وقتی کلید در سراسر سند هرگز تغییر نمی‌کند

کتابخانهٔ اجرایی دلفی هیچ واحد ایمپورت bcrypt ندارد، پس نقاط ورود را مستقیم اعلام کنید. کلاس زیر تمام وضعیت رمزنگاری را یک بار می‌سازد و سپس هر تعداد شیء را بدون تخصیص در حالت پایدار رمز می‌کند:

مقایسهٔ PDF میان یک تابع کمکی ساده‌لوحانهٔ AES-256 که ارائه‌دهندهٔ CNG ویندوز، حالت زنجیره‌سازی و زمان‌بندی کلید را برای هر شیء PDF از نو می‌سازد و سازندهٔ بالابرده‌شدهٔ TPdfObjectEncryptor که حلقه‌اش هیچ تخصیصی ندارد
بازکردن دوبارهٔ ارائه‌دهندهٔ CNG و ساخت مجدد زمان‌بندی کلید حدود 150 میکروثانیه به‌ازای هر شیء خرج برمی‌دارد؛ سازندهٔ بالابرده‌شده این بها را یک بار می‌پردازد و حلقهٔ گرم هیچ تخصیصی نمی‌دهد
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;    // بافر موقت متن رمزشده، رشد می‌کند و بعد می‌ماند
  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

  // پرس‌وجوی اندازه: padding در CBC همیشه 1..16 بایت می‌افزاید، پس Need > Length(Plain)
  IVWork := IV;  // BCryptEncrypt هنگام زنجیره‌سازی بافر IV را جلو می‌برد
  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: نخست IV شانزده‌بایتی، سپس متن رمزشدهٔ padding‌دار
  Dest.WriteBuffer(IV[0], 16);
  Dest.WriteBuffer(FScratch[0], Written);
end;

سه جزئیات باربر هستند. پرس‌وجوی اندازه — نخستین فراخوانی BCryptEncrypt با بافر خروجی nil — طول متن رمزشدهٔ padding‌دار را برمی‌گرداند که هرگز با طول ورودی برابر نیست؛ padding قطعی است، پس می‌توانید خودتان ((Len div 16) + 1) * 16 را حساب کنید و شمار فراخوانی‌ها را نصف کنید، اما پرس‌وجو قرارداد مستندشده است. دوم، BCryptEncrypt هنگام زنجیره‌سازی بافر IV را در جای خود جلو می‌برد، پس یک نسخهٔ کاری به هر فراخوانی می‌رود و IV دست‌نخورده در خروجی می‌نشیند. سوم، FScratch فقط رشد می‌کند، تا اندازهٔ بزرگ‌ترین شیء فایل، و پس از آن حلقه هیچ تخصیصی نمی‌دهد

ارزش بازاستفاده از هندل، اندازه‌گیری‌شده

فایلی که این تمرین را تحمیل کرد یک بایگانی اسکن‌شدهٔ وام 1.8 گیگابایتی بود: 412,000 شیء رمزشده که پس از کسر ساختار متن آشکار 1,710 مگابایت بار حمل می‌کردند. همان ماشین، همان فایل، ذخیره‌سازی NVMe، یک نخ:

  • راه‌اندازی به‌ازای هر فراخوانی (ارائه‌دهنده باز و کلید درون تابع کمکی تولید می‌شود): فاز رمزگذاری 71.3 ثانیه — 1,710 مگابایت ÷ 71.3 ثانیه ≈ 24 مگابایت بر ثانیه
  • وضعیت بالابرده‌شده (کلاس بالا): 9.6 ثانیه — 1,710 مگابایت ÷ 9.6 ثانیه ≈ 178 مگابایت بر ثانیه

تفاوت 61.7 ثانیه در 412,000 فراخوانی است، یعنی تقریباً 150 میکروثانیه به‌ازای هر فراخوانی که صرف بازکردن یک ارائه‌دهنده، تنظیم حالت زنجیره‌سازی و ساخت دوبارهٔ زمان‌بندی کلیدی می‌شود که هرگز تغییر نکرده بود. هیچ‌کدام از این‌ها رمزنگاری نبود. با AES-NI، رمزگذاری CBC روی بافرهای بزرگ روی یک هسته نزدیک 1.4 گیگابایت بر ثانیه پیش می‌رود، پس خود حساب AES تنها حدود 1.2 ثانیه از آن 9.6 ثانیه را می‌گیرد؛ بیشتر باقی‌مانده دو گذار حالت کاربر BCryptEncrypt به‌ازای هر شیء به‌علاوهٔ تولید IV برای هر شیء است. دسته‌ای کردن IVها — یک فراخوانی BCryptGenRandom که 4,096 عدد از آن‌ها را پر می‌کند — اجرا را به 8.9 ثانیه رساند. فراتر از آن به کف به‌ازای شیءِ این API می‌رسید و اهرم باقی‌مانده موازی‌سازی است: اشیای /V 5 زیر کلید فایل مشترک مستقل‌اند، پس چهار نخ کارگر که هر کدام یک شیء کلید دارند فاز را به 3.1 ثانیه رساندند تا جایی که نویسندهٔ خروجی نقطهٔ ترتیبی‌سازی شد

PDF: نمودار میله‌ای زمان‌های رمزگذاری AES-256 روی یک PDF اسکن‌شدهٔ 1.8 گیگابایتی که از 71.3 ثانیه به 9.6 ثانیه با وضعیت بالابرده‌شدهٔ CNG، 8.9 ثانیه با IVهای دسته‌ای و 3.1 ثانیه با چهار نخ کارگر می‌رسد
اندازه‌گیری روی اسکنی 1.8 گیگابایتی با 412,000 شیء: بالا بردن وضعیت CNG حدود 61.7 ثانیه سربار محض API را پس می‌گیرد، IVهای دسته‌ای بیشتر می‌تراشند و چهار نخ کارگر پیش از ترتیبی شدن کار نویسنده به 3.1 ثانیه می‌رسند

بازنویسی کامل در برابر ذخیرهٔ افزایشی

دانه‌بندی تعیین می‌کند که یک ذخیره چقدر خرج دارد. افزودن رمزگذاری به سند متن آشکار موجود بنا به تعریف هر شیء را بازنویسی می‌کند: هر استریم و رشته هم محتوا و هم طولش تغییر می‌کند، هر افست ارجاع متقاطع جابه‌جا می‌شود، و هیچ مسیر افزایشی وجود ندارد. آن را مثل یک بازنویسی ترتیبی کامل بودجه‌بندی کنید و در فایلی موقت بنویسید که بعد روی هدف تغییر نام می‌یابد، چون در غیر این صورت یک فروپاشی وسط رمزگذاری فایلی نیمه‌رمز به جا می‌گذارد که هیچ رمز عبوری بازش نمی‌کند

جهت معکوس همان ارزان است. وقتی فایلی رمز شد، یک به‌روزرسانی افزایشی اشیای تازه را با همان کلید فایل رمز و الحاق می‌کند و هر بایت اصلی را دست‌نخورده می‌گذارد. مهر زدن یک حاشیه‌نویسی تأیید روی بایگانی رمزشدهٔ دو گیگابایتی چند کیلوبایت خروجی الحاقی خرج دارد، نه یک بازنویسی دو گیگابایتی. نتیجهٔ خط پردازشی: یک بار رمز کنید، در واپسین گام کار، و بگذارید دست‌کاری‌های بعدی سوار ذخیره‌های افزایشی شوند. چرخش رمز عبوری که کلید فایل را هم بچرخاند دوباره یک بازنویسی کامل است — آن را هم مثل بازنویسی زمان‌بندی کنید

اندازه‌گیری توان عبوری بدون فریب دادن خود

ادعاهای توان عبوری رمزگذاری معمولاً در صورت کسر، در مخرج، یا در هر دو نادرست‌اند. صورت باید بایت‌های بار باشد: مجموع طول استریم‌ها و رشته‌هایی که واقعاً پس از فشرده‌سازی از AES گذشته‌اند، عددی که نویسنده می‌تواند حین کار جمع بزند. اندازهٔ فایل آن را بیش از واقع نشان می‌دهد — بایگانی بالا روی دیسک 1.8 گیگابایت است، اما تنها 1,710 مگابایت آن هرگز به رمز دست می‌زند. مخرج باید تنها فاز رمزگذاری باشد که با TStopwatch از System.Diagnostics کروشه‌گذاری شده و تجزیه، deflate و ورودی/خروجی دیسک بیرون کروشه بمانند. آن‌ها را داخل کنید و همان کد رمزگذاری روی فایلی که فقط بدتر فشرده می‌شود چند برابر کندتر اندازه‌گیری خواهد شد. ارقام بالا دقیقاً از این رو قابل مقایسه‌اند که هر دو سوی تقسیم فقط رمزگذاری است

هیچ‌یک از این‌ها لازم نیست کدی باشد که خودتان مالکش هستید. HotPDF همین مهندسی را پشت ویژگی‌های کامپوننت می‌پیچد — ActivateProtection، CryptKeyLength، UseAES256R6 — در ارتفاعی مناسب برنامه‌های تعاملی VCL، با دام‌های ترتیب انتساب که در مقالهٔ AES-256 مربوط به HotPDF پوشش داده شده‌اند. برای خطوط پردازش بی‌ناظر، PDF Library for Delphi رمزگذاری AES-256 بازنگری 6 را با یک فراخوانی EncryptFile در Strength 4 روی فایل‌های موجود اعمال می‌کند و سپس آنچه روی دیسک نشسته را راستی‌آزمایی می‌کند، جریان کاری که مقالهٔ ممیزی رمزگذاری PDF Library for Delphi آن را می‌پیماید

مسیرهای رمزگذاری شرح‌داده‌شده در اینجا در HotPDF Delphi Component برای دلفی و C++Builder و در کتابخانهٔ PDF Library for Delphi عرضه می‌شوند؛ هر دو صفحهٔ محصول مرجع کامل رمزگذاری را در خود دارند