مقاله فنی

کلیدهای رمزنگاری PDF تکراری روی FPC: fix RNG در PDFiumPas

قبل از نسخهٔ 3.114.8، PDFiumPas کلیدهای رمزنگاری PDF را روی تارگت‌های غیر ویندوز با تابع Random کتابخانهٔ ران‌تایم تولید می‌کرد، و چون هیچ‌کس Randomize را صدا نزد، هر پروسه همان دنبالهٔ بایتی را تولید می‌کرد. بیلدهای Free Pascal روی Linux و macOS بنابراین کلیدهای file encryption و saltها و IVهای CBC و پیشوندهای nonce مربوط به AES-GCM را یکسان، بار بعد از بار می‌نوشتند. نسخهٔ 3.114.8 به‌جایش /dev/urandom را می‌خواند و وقتی نمی‌تواند استثنا می‌دهد

خود نقص یک حلقهٔ چهارخطی است. درس مفیدترش این است که چرا مجموعه‌ای از تست‌ها که صدها سند را رمز می‌کند و رمزگشایی می‌کند، با AESV3 و AESV4، با و بدون PDF MAC، تمام مدت سبز ماند. تصادفی‌بودنی که به‌ازای هر پروسه ثابت است از دید هر تستی که داخل یک پروسه اجرا می‌شود پنهان است، و تست‌های رمزنگاری معمولاً دقیقاً همین‌طور نوشته می‌شوند

PDFiumPas کجا به بایت تصادفی نیاز دارد؟

هر بایت تصادفی در استک رمزنگاری PDFiumPas از یک procedure تکی می‌آید، یعنی AesGenerateRandomBytes در یونیت FPdfAes، پس یک مبدأ بد همه‌جا را آلوده می‌کند. هندلر امنیتی استاندارد در ISO 32000-2 §7.6.4 و افزونهٔ AESV4 در ISO/TS 32003 این بایت‌ها را در این‌جاها مصرف می‌کنند:

  • کلید file encryption سی‌ودو بایتی که DeriveEncryptionKeys برای هر سند از نو تولید می‌کند و بعد زیر کلیدهای مشتق‌شده از رمز عبور در /UE و /OE wrap می‌شود
  • دو salt شانزده‌بایتی، یکی در 16 بایت آخر /U و یکی در 16 بایت آخر /O، که هر یک به یک validation salt هشت‌بایتی و یک key salt هشت‌بایتی شکسته می‌شود
  • بایت‌های 12 تا 15 متن آشکار پشت /Perms، که ISO 32000-2 قبل از رمز شدن بلوک زیر کلید فایل با دادهٔ تصادفی پرشان می‌کند
  • یک CBC IV شانزده‌بایتی که به هر رشته و stream رمزشده در یک سند AESV3 پیشوند می‌شود
  • یک پیشوند nonce هشت‌بایتی برای سندهای AESV4، با دنبال‌کردن یک شمارندهٔ 4 بایتی به-ازای-هر-آبجکت که از صفر شروع می‌شود
  • مقدار 32 بایتی /KDFSalt و کلید MAC وقتی EnableIntegrityProtection ست شده باشد
هر بایت تصادفی در استک رمزنگاری PDFiumPas از AesGenerateRandomBytes در FPdfAes به شش مصرف‌کننده جاری می‌شود: کلید 32 بایتی file encryption که در /UE و /OE wrap می‌شود، saltهای /U و /O، بایت‌های پرکنندهٔ /Perms، CBC IV مربوط به AESV3، پیشوند nonce مربوط به GCM در AESV4، و salt مربوط به KDF و کلید MAC
یک مولد مشترک یعنی یک مبدأ بد یک‌جا مواد کلیدی همه‌جا را آلوده می‌کند، و همین دلیل است که fix در یک procedure تکی فرود آمد نه در هر نقطهٔ فراخوان

چرا هر پروسه همان کلید را تولید می‌کرد؟

AesGenerateRandomBytes مولد سیستم‌عامل را فقط روی ویندوز استفاده می‌کرد؛ در همه‌جای دیگر بافر را از مولد شبه‌تصادفی RTL پر می‌کرد، و آن مولد مگر برنامه Randomize را صدا بزند از RandSeed = 0 شروع می‌شود. کامنتی که بالای حلقه بود می‌گفت مولد از GetTickCount64 seed می‌شود. هیچ خط کدی هرگز این کار را نکرد، که یعنی کامنت تنها جایی بود که seed در آن وجود داشت:

// شاخهٔ غیر ویندوز AesGenerateRandomBytes قبل از 3.114.8
// (کامنت بالایش seed گرفتن از GetTickCount64 را قول می‌داد که هرگز اعمال نشد)
P := PByte(Buffer);
for I := 0 to Count - 1 do
  P[I] := Byte(Random(256));

دنباله با هر پروسه از نو شروع می‌شود و داخلش جلو می‌رود، پس اولین سندی که هر پروسه رمز می‌کند کلید فایلش با اولین سند هر پروسهٔ دیگر که همان بیلد را اجرا می‌کند یکی است، دومی با دومی، و همین‌طور تا آخر. کلید فایل در R5 و R6 و R7 اصلاً به رمز عبور وابسته نیست، چون رمز عبور فقط wrapش می‌کند، که یعنی هر کسی بتواند دنباله را بازتولید کند کلید را بدون دانستن رمز عبور دارد. AESV4 یک شکست دوم اضافه می‌کند: همان کلید با همان پیشوند هشت‌بایتی و شمارنده‌ای که از صفر ری‌استارت می‌شود، nonceهای GCM را تکرار می‌کند، که NIST SP 800-38D §8 صراحتاً منعش کرده. یک nonce تکراری GCM زیر یک کلید XOR دو متن آشکار را لو می‌دهد و subkey احراز هویت را افشا می‌کند، پس tagهایی که رمزنگاری AESV4-GCM و توکن PDF MAC رویشان حساب می‌کنند دیگر هیچ معنایی ندارند. محرمانگی و یکپارچگی با هم می‌روند

مولد RTL بی‌seed در PDFiumPas روی بیلدهای FPC غیر ویندوز: با RandSeed برابر 0 هر پروسه همان دنباله را تولید می‌کند، پس سند یک در پروسهٔ A همان کلید فایل سند یک در پروسهٔ B را حمل می‌کند، و AESV4 nonceهای GCM را تکرار می‌کند چون همان کلید با همان پیشوند و شمارندهٔ ری‌استارت‌شده از صفر ملاقات می‌شود
چون کلید فایل هرگز به رمز عبور وابسته نیست، هر کسی که بتواند دنباله را بازتولید کند کلید را یکسره دارد، و nonceهای تکراری GCM محرمانگی و یکپارچگی را با هم نابود می‌کنند

دامنه باریک‌تر از آن چیزی است که آن پاراگراف شاید القا کند. بیلدهای ویندوز هرگز تحت تأثیر نبودند، چون شاخهٔ ویندوز همیشه از طریق advapi32 با CRYPT_VERIFYCONTEXT CryptGenRandom را صدا می‌زد و شکستش استثنا می‌داد. چیزی که افشا شده بود خروجی بیلدهای غیر ویندوز قبل از 3.114.8 بود، که در عمل یعنی اپلیکیشن‌های Lazarus و Free Pascal روی Linux و macOS، و یک مدخل دیگر برای فهرست دام‌های Delphi در برابر FPC در بیلدهای PDFium

چرا Randomize هرگز fix درستی نبود؟

صدا زدن Randomize علامت را می‌پوشاند بدون اینکه مبدأ را درست کند، چون RandSeed یک مقدار 32 بیتی است و Randomize آن را از ساعت می‌گیرد. این تعداد دنباله‌های کلید ممکن را روی 2^32 سقف می‌زند، و دانستن تقریبی زمان نوشته شدن یک فایل جستجو را خیلی زیر آن می‌برد، که در برابر کلید AES با 256 بیت هیچ است. مواد کلیدی باید از مخزن انتروپی کرنل بیایند، پس AesGenerateRandomBytes در 3.114.8 /dev/urandom را می‌خواند، روی خواندن‌های کوتاه حلقه می‌زند، و اگر مخزن نتواند هر بایت درخواستی را بدهد استثنا می‌دهد:

Handle := FileOpen('/dev/urandom', fmOpenRead or fmShareDenyNone);
if Handle <> THandle(-1) then
try
  Remaining := Count;
  while Remaining > 0 do
  begin
    Got := FileRead(Handle, P^, Remaining);
    if Got <= 0 then
      Break;               // شکست یا پایان غیرمنتظرهٔ stream
    Inc(P, Got);
    Dec(Remaining, Got);
  end;
  if Remaining = 0 then
    Exit;
finally
  FileClose(Handle);
end;
raise Exception.Create('FPdfAes: /dev/urandom unavailable; refusing to emit predictable key/IV');

رد کردن عامدانه است و با همان چیزی جور است که شاخهٔ ویندوز همیشه وقتی CryptGenRandom در دسترس نبود می‌کرد. یک ذخیرهٔ رمزشدهٔ شکست‌خورده حادثه‌ای است که همان روز می‌فهمیدش؛ یک ذخیرهٔ موفق با کلیدهای قابل پیش‌بینی حادثه‌ای است که از زبان دیگران می‌فهمیدش. دو نتیجهٔ عملی می‌آید. یک container یا chroot مینیمال بدون /dev پرشده حالا به‌جای افت بی‌سروصدا در رمزنگاری شکست می‌خورد، پس mountش کنید. و چون استثنا از TPdf.SaveAsEncrypted بیرون می‌پرد بعد از اینکه فایل مقصد با fmCreate باز شده، یک فایل خروجی خالی جا می‌ماند که error handler شما باید پاکش کند

چرا تست‌های round trip هرگز آن را نگرفتند؟

یک تست round trip نمی‌تواند تصادف ثابت را ببیند، چون رمزگشایی هر کلید فایلی را که رمزنگاری انتخاب کرده برمی‌گرداند. تست سند را رمز می‌کند، با رمز عبور دوباره بازش می‌کند، کلید را از /UE باز می‌کند و هر آبجکت را رمزگشایی می‌کند؛ یک کلید قابل پیش‌بینی همان‌قدر خوب باز می‌شود و رمزگشایی می‌شود که یک کلید تصادفی، و tagهای GCM راستی‌آزمایی می‌شوند چون با همان کلید حساب شده‌اند. حتی تستی که دو بار رمز می‌کند و اظهار می‌کند دو خروجی فرق دارند پاس می‌شود، چون فراخوان دوم در همان پروسه بایت‌های بعدی دنباله را می‌کشد. خاصیتی که مهم است، یعنی یک کلید متفاوت در هر پروسه، فقط با مقایسهٔ خروجی بین پروسه‌ها قابل مشاهده است. هر وقت همان کد هم یک مقدار تولید کند و هم مصرفش کند، تست‌ها به کلاس‌های کامل نقص کورند، و تصادف خالص‌ترین مثال آن است

چطور می‌شود تصادف بودن کلید را بین پروسه‌ها آزمود؟

یک probe کوچک را دو بار به‌عنوان پروسه‌های جدا روی پلتفرم مقصد اجرا کنید و خروجی را مقایسه کنید. probe زیر DeriveEncryptionKeys را صدا می‌زند و salt ذخیره‌شده در بایت‌های 32 تا 47 مدخل /U را چاپ می‌کند. آن مقدار به‌صورت آشکار در هر فایل رمزشده نوشته می‌شود، پس چاپش در لاگ‌های CI چیزی را افشا نمی‌کند، ولی از همان مولدی می‌آید که کلید فایل:

تست تصادف بین-پروسه‌ای برای PDFiumPas: برنامهٔ SaltProbe مقدار DeriveEncryptionKeys را صدا می‌زند و hex بایت‌های 32 تا 47 مدخل /U را چاپ می‌کند، job آن را دو بار به‌عنوان پروسه‌های جدا اجرا می‌کند و وقتی دو خط یکسان‌اند شکست می‌خورد، و PDFهای ارسال‌شده با 16 بایت آخر رشته‌های /U مقایسه می‌شوند
تصادف ثابت داخل یک پروسه نادیده می‌ماند چون رمزگشایی هر کلیدی را که رمزنگاری انتخاب کرده برمی‌گرداند، پس خاصیت مهم فقط با مقایسهٔ خروجی بین پروسه‌ها قابل مشاهده است
program SaltProbe;
{$mode delphi}
uses
  SysUtils, FPdfEncrypt;
var
  Opts: TPdfEncryptOptions;
  Keys: TPdfEncryptionKeys;
  I: Integer;
  Hex: string;
begin
  Opts := TPdfEncryptOptions.Default;
  Opts.UserPassword := 'probe';
  Opts.Revision := erR6;
  DeriveEncryptionKeys(Opts, Keys);
  Hex := '';
  for I := 32 to 47 do          // /U = hash 32 بایتی + salt 16 بایتی
    Hex := Hex + IntToHex(Keys.UEntry[I], 2);
  WriteLn(Hex);                 // باید در هر اجرا فرق کند
end.

probe را برای هر تارگت غیر ویندوز به بیلد وصل کنید: دو بار اجرایش کنید، اگر دو خط یکی شد job را fail کنید. همان مقایسه روی فایل‌های در گردش هم کار می‌کند. دو PDF رمزشده که اجراهای متفاوت همان اپلیکیشن نوشته‌اند بردارید، رشته‌های /U را از دیکشنری‌های Encryptشان بخوانید و 16 بایت آخر را مقایسه کنید؛ saltهای یکسان یک بیلد تحت تأثیر را شناسایی می‌کنند، و سندها باید از plaintextشان با 3.114.8 یا بعدتر دوباره رمز شوند تا هر یک کلید فایل تازه‌ای بگیرد. عادت کلی این است که مسیرهای کد غیر ویندوز را روی خود پلتفرم تمرین کنید به‌جای اعتماد به اجرای ویندوز، همان استدلالی که پشت backend تایم‌استمپ libcurl برای بیلدهای غیر ویندوز هم هست

PDFiumPas یک کامپوننت PDF برای Delphi و Lazarus است که روی موتور PDFium ساخته شده، با AES-256 و AES-GCM و توکن PDF MAC که به‌صورت بومی در Pascal پیاده شده و مواد کلیدی که روی هر پلتفرمی از مولد سیستم‌عامل می‌آید. جزئیات و دانلودها در صفحهٔ کامپوننت PDFium برای Delphi است