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 با پسورد و فلگهای دسترسی سمت دیگر این معامله را پوشش میدهد
نوشتن گیرندههای 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 رفتوبرگشتی باید در سویت تستت باشد، نه فقط یک بررسی حجم فایل
چرا ترتیب /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 تضمین نمیکند
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