مقاله فنی

رمزنگاری گواهی در PDF با Delphi: RSA-OAEP و ECDH

HotPDF یک PDF را برای دارندگان گواهی مشخص از طریق security handler کلید عمومی ISO 32000 رمز می‌کند: EnablePubKeyEncryption یک seed تصادفی 20 بایتی می‌گیرد و هر گیرنده پاکت CMS مخصوص خودش را می‌گیرد، که برای کلیدهای RSA با AddPubKeyRecipientCertificate ساخته می‌شود (انتقال کلید RSA-OAEP) یا برای کلیدهای منحنی بیضوی با AddPubKeyAgreementRecipientWithSecret (ECDH روی P-256 و P-384 و P-521 و X25519 یا X448). هیچ‌کس پسوردی به اشتراک نمی‌گذارد؛ هر کس کلید خصوصی منطبقی داشته باشد فایل را باز می‌کند

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

رمزنگاری PDF مبتنی بر گواهی چه فرقی با پسورد دارد؟

یک PDF رمزشده با کلید عمومی، کلید فایلش را از یک seed تصادفی به‌علاوهٔ بایت‌های دقیق هر پاکت گیرنده مشتق می‌کند، نه از هیچ چیزی که آدم تایپ کند. این handler در ISO 32000-1 §7.6.4 (§7.6.5 در ISO 32000-2) توصیف شده و پاکت‌ها ساختارهای CMS EnvelopedData طبق تعریف RFC 5652 هستند. HotPDF /Filter /Adobe.PubSec با /SubFilter /adbe.pkcs7.s5 می‌نویسد؛ برای AES-256 یعنی /V 5 و یک درایهٔ /DefaultCryptFilter زیر /CF با /CFM /AESV3، و آرایهٔ /Recipients داخل همان crypt filter زندگی می‌کند. هر پاکت 24 بایت را رمز می‌کند: seed بیست‌بایتی به‌علاوهٔ واژهٔ دسترسی 32 بیتی همان گیرنده. مقدار /P در dictionary رمزنگاری فقط یک جای‌نگهدار است، چون دسترسی‌های واقعی داخل هر پاکت سفر می‌کنند. در زمان بارگذاری یک reader یکی از پاکت‌ها را باز می‌کند، seed را بازیابی می‌کند و seed را با هر پاکت به ترتیب آرایهٔ /Recipients هش می‌کند (SHA-256 برای AES-256، SHA-1 برای رمزهای قدیمی‌تر) تا کلید فایل دوباره ساخته شود. اگر هنوز بین این مدل و پسوردهای معمولی داری تصمیم می‌گیری، راهنمای رمزنگاری AES-256 با پسورد و فلگ‌های دسترسی سمت دیگر این معامله را پوشش می‌دهد

نمودار رمزنگاری کلید عمومی در HotPDF: EnablePubKeyEncryption یک seed بیست‌بایتی را قطعی می‌کند، هر پاکت CMS EnvelopedData آن 20 بایت به‌علاوهٔ یک واژهٔ دسترسی 32 بیتی را داخل /Filter /Adobe.PubSec با /SubFilter /adbe.pkcs7.s5 و /CFM /AESV3 رمز می‌کند، و reader یکی از پاکت‌ها را باز می‌کند، seed را بازیابی و با هر درایهٔ /Recipients به ترتیب آرایه هش می‌کند تا کلید فایل دوباره ساخته شود
مقدار /P در dictionary رمزنگاری فقط جای‌نگهدار است چون دسترسی‌های واقعی داخل هر پاکت سفر می‌کنند، و هیچ چیز در پایین‌دست حق ندارد آرایه‌ای که digest رویش اجرا می‌شود را جابه‌جا یا دوباره کدگذاری کند

نوشتن گیرنده‌های RSA با EnablePubKeyEncryption

برای گواهی‌های RSA، EnablePubKeyEncryption را با aes256 صدا بزن، بعد قبل از BeginDoc به‌ازای هر گواهی DER-کدشده یک بار AddPubKeyRecipientCertificate را صدا بزن. این هلپر یک پاکت RSAES-OAEP درون-فرایندی می‌سازد با مقادیر THPDFRSAOAEPHash برای digest مربوط به OAEP و digest مربوط به MGF1 (rohSHA256 و rohSHA384 یا rohSHA512)، و محتوای پاکت را با AES-256-CBC رمز می‌کند

uses
  System.SysUtils, System.IOUtils, HPDFDoc, HPDFCrypt, HPDFRSA;

procedure WriteAuditPack(const OutFile: string);
var
  Pdf: THotPDF;
  Seed: AnsiString;
begin
  SetLength(Seed, 20);                      // دقیقاً 20 بایت، حتی برای AES-256
  AESGenerateRandomBytes(@Seed[1], Length(Seed));
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := OutFile;
    Pdf.EnablePubKeyEncryption(Seed, aes256, True);   // نوع کلید پیش‌فرض aes128 است
    // بازبین A اجازهٔ چاپ دارد؛ بازبین B فقط می‌تواند بخواند و استخراج کند
    Pdf.AddPubKeyRecipientCertificate(TFile.ReadAllBytes('reviewer-a.cer'),
      [prPrint, prPrint12bit, prExtractContent], rohSHA256, rohSHA256);
    Pdf.AddPubKeyRecipientCertificate(TFile.ReadAllBytes('reviewer-b.cer'),
      [prExtractContent]);
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 11);
    Pdf.CurrentPage.TextOut(72, 720, 0, 'Q3 audit pack');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

سه جزئیات در آن فهرست باربردنی‌اند. اول، طول seed برای هر نوع کلیدی روی 20 بایت ثابت است، AES-256 هم شامل می‌شود؛ EnablePubKeyEncryption روی هر طول دیگری استثنا می‌دهد. دوم، EnablePubKeyEncryption پیش‌فرضش aes128 است و هر دو هلپر گواهی از اجرا امتناع می‌کنند مگر اینکه نوع کلید aes256 باشد، پس فراموش کردن آرگومان دوم استثنای «certificate envelopes require aes256» را می‌گیرد. رمزهای قدیمی (k40 و k128 و aes128) هنوز کار می‌کنند، اما فقط از طریق AddPubKeyRecipient با پاکتی که جای دیگری ساخته‌ای. سوم، رمزنگاری کلید عمومی AES-256 یک قابلیت PDF 2.0 است، پس HotPDF نسخهٔ سند را خودکار به 2.0 می‌رساند. با ست بودن StrictVersionLock روی نسخه‌ای پایین‌تر، EnablePubKeyEncryption بدون فعال کردن چیزی برمی‌گردد و شکست فقط در سطر بعدی به‌شکل «call EnablePubKeyEncryption first» ظاهر می‌شود. عوض کردن رمزنگاری وسط یک به‌روزرسانی افزایشی بلافاصله EInvalidOpException می‌دهد

افزودن گیرنده‌های ECDH: از P-256 تا X25519 و X448

برای گواهی‌های منحنی بیضوی، AddPubKeyAgreementRecipientWithSecret یک گیرندهٔ توافق کلید CMS می‌نویسد (KeyAgreeRecipientInfo، ساختار KARI از RFC 5753 با پروفایل X25519 و X448 از RFC 8418) و راز مشترک ECDH را درون-فرایندی حساب می‌کند. منحنی را با یک مقدار THPDFPubKeyAgreementScheme انتخاب می‌کنی: pkasECDHP256 و pkasECDHP384 و pkasECDHP521 و pkasX25519 یا pkasX448. scheme باید با کلید داخل گواهی بخواند، وگرنه فراخوانی «Certificate key does not match the requested agreement scheme» می‌دهد. زیر کاپوت، هر پاکت یک UKM تصادفی تازهٔ 32 بایتی می‌گیرد، یک کلید رمزنگاری کلید که با KDF مربوط به stdDH مشتق شده (SHA-256 برای P-256 و X25519، SHA-384 برای P-384، SHA-512 برای P-521 و X448)، و یک AES-256 key wrap طبق تعریف RFC 3394. خود راز مشترک از کد منحنی Pascal خالص می‌آید بدون دخالت هیچ crypto provider پلتفرمی؛ مقالهٔ محاسبات منحنی NIST به Pascal خالص توضیح می‌دهد آن لایه چطور ساخته و راستی‌آزمایی شد. برای منحنی‌های مونتگومری می‌شود کل جفت کلید ephemeral را محلی تولید کرد:

uses
  System.SysUtils, System.IOUtils, HPDFDoc, HPDFCrypt, HPDFPubSec,
  HPDFKeyAgreement;

procedure AddLegalRecipient(Pdf: THotPDF);
var
  Scalar, OriginatorPublic: TBytes;
begin
  // scalar تصادفی تازه به‌ازای هر پاکت؛ clamping داخل نردبان اتفاق می‌افتد
  SetLength(Scalar, 32);
  AESGenerateRandomBytes(@Scalar[0], Length(Scalar));
  try
    OriginatorPublic := HPDFX25519PublicFromScalar(Scalar);
    Pdf.AddPubKeyAgreementRecipientWithSecret(
      TFile.ReadAllBytes('legal-x25519.cer'),
      [prPrint, prExtractContent], pkasX25519,
      OriginatorPublic, Scalar,
      []);   // OwnPublicPoint: فقط برای منحنی‌های NIST معنا دارد
  finally
    HPDFSecureClearBytes(Scalar);
  end;
end;

منحنی‌های NIST از فراخوانی‌کننده بیشتر می‌خواهند. HotPDF هلپرهای کلید عمومی فقط برای X25519 و X448 دارد (HPDFX25519PublicFromScalar و HPDFX448PublicFromScalar)، پس برای P-256 و P-384 و P-521 جفت کلید ephemeral را با ابزار خودت تولید می‌کنی و یک scalar big-endian دقیقاً به اندازهٔ فیلد (32 یا 48 یا 66 بایت) به‌علاوهٔ نقطهٔ فشرده‌نشدهٔ متناظر 0x04||X||Y را به‌عنوان OriginatorPublicKey می‌دهی. HotPDF نقطهٔ گیرنده را با معادلهٔ منحنی راستی‌آزمایی می‌کند، اما نمی‌تواند بررسی کند کلید عمومی مبدأت واقعاً مالِ scalar توست. دو نیمهٔ ناهمخوان همچنان یک پاکت کاملاً خوش‌فرم تولید می‌کنند که هیچ گیرنده‌ای نمی‌تواند بازش کند، و به همین دلیل یک load رفت‌وبرگشتی باید در سویت تستت باشد، نه فقط یک بررسی حجم فایل

نمودار توافق ECDH در HotPDF: AddPubKeyAgreementRecipientWithSecret راز مشترک را با کد منحنی Pascal خالص مشتق می‌کند، یک UKM تازهٔ 32 بایتی را از KDF مربوط به stdDH عبور می‌دهد با SHA-256 برای P-256 و X25519، SHA-384 برای P-384، SHA-512 برای P-521 و X448، بعد کلید محتوا را با AES-256 key wrap طبق RFC 3394 می‌پیچد تا پاکت KeyAgreeRecipientInfo ساخته شود
مقدار scheme از pkasECDHP256 تا pkasX448 باید با کلید گواهی بخواند، و دو نیمهٔ ناهمخوان scalar و نقطهٔ عمومی همچنان پاکتی خوش‌فرم تولید می‌کنند که هیچ گیرنده‌ای نمی‌تواند بازش کند

چرا ترتیب /Recipients مهم است؟

ترتیب /Recipients مهم است چون کلید فایل یک digest روی seed و همهٔ پاکت‌ها به ترتیب آرایه است، پس writer و reader باید همان بایت‌ها را در همان توالی هش کنند. HotPDF پاکت‌ها را به ترتیبی که اضافه‌شان می‌کنی نگه می‌دارد و بدون تغییر می‌نویسد، یعنی می‌توانی گیرنده‌ها را به هر ترتیبی که دوست داری اضافه کنی، اما هیچ چیز در پایین‌دست حق ندارد آن آرایه را جابه‌جا کند، دوباره کدگذاری کند یا «مرتب» کند. بیشتر باگ‌های واقعی این حوزه تغییری از همان تم بودند، جایی که دو طرف بایت‌های کمی متفاوت را هش می‌کردند:

  • ذخیرهٔ آرایه‌های داینامیک در یک TList از طریق Add فقط یک pointer خام نگه می‌دارد در حالی که شمارندهٔ ارجاع با متغیر محلی می‌ماند. اولین SetLength بعدی بافر را آزاد و ممکن است دوباره استفاده‌اش کند، پس هر خانه به آخرین پاکت alias می‌شد و فایل‌های چند-گیرنده کلید غلط مشتق می‌کردند. fix این است که یک کپی مالکیت‌دار ذخیره کنی با List.Add(Pointer(System.Copy(Bytes)))
  • باز کردن پاکت‌ها DER را درجا parse می‌کند و گذر بازیابی کلید در ابتدا همان آرایه‌های زنده را هش می‌کرد. reader حالا قبل از هر دست زدنی، کپی‌های دست‌نخوردهٔ هر پاکت را snapshot می‌کند و digest روی snapshotها اجرا می‌شود
  • DER باینری که از یک TStringList یونیکد عبور کند بایت‌های $80 به بالا را با code page دوباره کدگذاری می‌کند، پس HotPDF پاکت‌ها را به‌صورت متن hex داخلی نگه می‌دارد
  • رشته‌های رمزشده و باینری باید به‌صورت hex string نوشته شوند. یک رشتهٔ literal مشمول نرمال‌سازی انتهای خط است، جایی که CR و LF و CRLF همه به یک LF تبدیل می‌شوند (ISO 32000-1 §7.3.4.2) و این بی‌سروصدا ciphertext را بازنویسی می‌کند. HotPDF هر درایهٔ /Recipients را به‌صورت hex string صادر می‌کند و از رمزنگاری رشته معافش می‌دارد، چون هر reader قبل از داشتن هر کلیدی به پاکت‌ها نیاز دارد
  • اولین بایت یک BIT STRING در DER تعداد بیت‌های بلااستفاده را می‌شمارد و برای کلیدهای هم‌تراز-بایتی باید صفر باشد. بی‌مقدار گذاشتنش بعد از SetLength هرچه روی stack بود را می‌نوشت و یک unwrapper سخت‌گیر کلید مبدأ را رد می‌کرد، پس یک فایل گاه‌به‌گاه با همان کلیدی که برایش نوشته شده بود باز نمی‌شد
  • وقتی همان کلید همچنان نمی‌تواند رمزگشایی کند، لایه‌به‌لایه مقایسه کن: کلید فایل، بعد پیشوند ciphertext (یعنی IV)، بعد کلید object، بعد plaintext. باگ درست بعد از اولین لایه‌ای که ناخوان است زندگی می‌کند

چطور یک PDF رمزشده با گواهی را با کلید خصوصی باز کنیم؟

برای باز کردن یک PDF رمزشده با گواهی، material کلید خصوصی را قبل از صدا زدن LoadFromFile ثبت کن، چون HotPDF کلید فایل را در گذر ساختاری بازیابی می‌کند. یک کلید RSA یا EC که با HPDFParsePFX parse شده را به PubSecKeyMaterial بده، کلیدهای RSA بیشتر را با AddPubSecKeyMaterial اضافه کن و scalarهای خام ECDH را با AddPubSecAgreementKeyMaterial(CurveOID, PrivateScalar, OwnPublicPoint) ثبت کن، با ثابت‌های HPDFOIDX25519 و HPDFOIDX448 و HPDFOIDECP256 و HPDFOIDECP384 یا HPDFOIDECP521. منحنی‌های NIST نقطهٔ عمومی فشرده‌نشدهٔ خودِ گیرنده را می‌خواهند؛ منحنی‌های مونتگومری نادیده‌اش می‌گیرند

uses
  System.SysUtils, System.IOUtils, HPDFDoc, HPDFPFX, HPDFKeyAgreement;

procedure OpenAuditPack(const LegalScalar: TBytes);
var
  Reader: THotPDF;
begin
  Reader := THotPDF.Create(nil);
  try
    Reader.AutoLaunch := False;
    Reader.PubSecKeyMaterial :=
      HPDFParsePFX(TFile.ReadAllBytes('reviewer-a.pfx'), 'pfx-password');
    Reader.AddPubSecAgreementKeyMaterial(HPDFOIDX25519, LegalScalar, nil);
    // اختیاری: پاکت را مستقیم انتخاب کن به‌جای امتحان کردن همه
    Reader.PubSecRecipientQuery :=
      function(Context: Pointer; RecipientCount: Integer): Integer
      begin
        Result := -1;   // -1 = امتحان کردن هر پاکت به ترتیب
      end;
    Reader.LoadFromFile('audit-pack.pdf', '');
    Writeln('Pages: ', Reader.GetLoadedPageCount);
  finally
    Reader.Free;
  end;
end;

بدون callback، HotPDF هر پاکت را با هر کلید ثبت‌شده امتحان می‌کند: اول کلید اصلی، بعد هر کلید RSA اضافی، بعد material مربوط به EC. PubSecRecipientQuery تعداد پاکت‌ها را می‌گیرد و یک ایندکس صفرمبنا یا 1- برمی‌گرداند و ایندکسی بیرون از آرایه به‌جای clamp شدن استثنا می‌دهد. توجه کن AddPubSecKeyMaterial فقط material مربوط به RSA می‌پذیرد (روی modulus و نمای خصوصی اصرار می‌ورزد)، پس کلیدهای EC جایشان PubSecKeyMaterial یا AddPubSecAgreementKeyMaterial است. وقتی هیچ کلیدی هیچ پاکتی را باز نمی‌کند، مرحلهٔ بازیابی به‌جای استثنا دادن بدون کلید فایل برمی‌گردد، پس راستی‌آزمایی کن که محتوایی که انتظار داری واقعاً رمزگشایی شده، نه اینکه به بازگشتن فراخوانی load اعتماد کنی

نمودار بارگذاری کلید خصوصی در HotPDF: PubSecKeyMaterial کلید اصلی RSA یا EC از HPDFParsePFX را حمل می‌کند، AddPubSecKeyMaterial فقط کلیدهای RSA اضافه می‌کند، AddPubSecAgreementKeyMaterial scalarهای خام ECDH را زیر OIDهای منحنی از HPDFOIDX25519 تا HPDFOIDP521 ثبت می‌کند، و در LoadFromFile ارائه‌دهنده اول کلید اصلی را امتحان می‌کند، بعد هر کلید RSA اضافی، بعد material مربوط به EC را روی هر پاکت
وقتی هیچ کلیدی هیچ پاکتی را باز نمی‌کند مرحلهٔ بازیابی بدون کلید فایل برمی‌گردد نه اینکه استثنا بدهد، پس راستی‌آزمایی کن محتوا واقعاً رمزگشایی شده یا پاکت را از طریق PubSecRecipientQuery پین کن

چه چیزی را HotPDF تضمین نمی‌کند

HotPDF تضمین می‌کند writer و reader خودش بایت‌به‌بایت با هم می‌خوانند و پاکت‌هایی می‌سازد که از ساختارهای CMS نقل‌شده در بالا پیروی می‌کنند. تضمین نمی‌کند هر viewerی هر ترکیبی را باز کند. پشتیبانی از انتقال کلید RSA-OAEP و گیرنده‌های X25519 یا X448 بین readerها و نسخه‌ها متفاوت است و ما نتایج سازگاری برای آن ترکیب‌ها منتشر نکرده‌ایم. اگر سندی باید در یک viewer مشخص باز شود، یک فایل تستی برای یک گواهی تستی با همان نوع کلید رمز کن و قبل از تعهد به یک scheme، همان‌جا بازش کن. دسترسی‌هایی که داخل پاکت حمل می‌شوند سیاستی می‌مانند که نرم‌افزار همخوان به آن احترام می‌گذارد، دقیقاً مثل زیر رمزنگاری پسوردی. کیفیت seed هم مسئولیت توست: AESGenerateRandomBytes برای همین کار آنجاست و HotPDF کپی خودش از seed را وقتی کلید فایل مشتق شد پاک می‌کند. اگر همچنین لازم داری یک رشته یا stream یا پیوست از crypt filter دیگری استفاده کند، راهنمای سیاست crypt filter برای StmF و StrF و EFF نشان می‌دهد public-key handler چه نام‌های فیلتری را می‌پذیرد

رمزنگاری گواهی، پاکت‌های گیرندهٔ RSA-OAEP و ECDH، و بارگذاری کلید خصوصی همه در کامپوننت PDF در Delphi از HotPDF عرضه می‌شوند، در کنار رمزنگاری پسوردی، امضاهای دیجیتال و بقیهٔ جعبه‌ابزار ISO 32000 برای Delphi و C++Builder