مقاله فنی

امضای PDF از Cert Store در HotPDF: ترتیب بایت CNG در برابر CAPI

HotPDF یک PDF را در برابر گواهی‌ای که از پیش در فروشگاه گواهی ویندوز (Windows Certificate Store) نشسته امضا می‌کند، با این کار که digest را به خود ویندوز می‌سپارد و ویندوز آن درخواست را از طریق یکی از دو بک‌اند کلید خصوصی تکمیل می‌کند: CNG که امضای RSA را به‌صورت big-endian برمی‌گرداند، یا CSP قدیمی CryptoAPI که آن را little-endian برمی‌گرداند. این دو را با هم اشتباه بگیرید و امضای CMS که HotPDF جاسازی می‌کند برای هر بک‌اندی که واقعاً پاسخ داده، بایت‌هایش برعکس می‌شود؛ در نتیجه یک اعتبارسنج مطابق با استاندارد امضا را نامعتبر گزارش می‌کند، در حالی که هیچ بایتی از سند دستکاری نشده است

پشت این یک جمله، دو مشکل بی‌ربط به هم پنهان شده و امضاکننده‌ی گواهی سیستمی HotPDF باید پیش از هر امضایی هر دو را حل کند. ناهماهنگی ترتیب بایت خاموش است: فراخوانی امضا همچنان True برمی‌گرداند، PDF همچنان باز می‌شود، و خرابی فقط زمانی نمایان می‌شود که یک نمایشگر ساختار CMS را پیمایش کند و آن را رد کند. مشکل دوم بلند و مختص C++Builder است: نیم‌دوجین تابع از crypt32 حاضر به لینک شدن نیستند، چون کتابخانه import که RAD Studio عرضه می‌کند آن‌ها را export نمی‌کند. هیچ‌کدام از این دو مشکل زمانی رخ نمی‌دهد که فقط با فایل PFX امضا کنید، به همین دلیل معمولاً گریبان توسعه‌دهندگانی را می‌گیرد که از امضای تک‌فراخوانی مبتنی بر PFX به سمت گواهی‌ای می‌روند که واحد IT از پیش در پروفایل کاربر نصب کرده است

انتخاب گواهی از فروشگاه

HotPDF این مسیر را از طریق HPDFSignPDFStreamWithSystemCertificate و HPDFSignPDFFileWithSystemCertificate در معرض دید می‌گذارد، که هر دو با یک رکورد THPDFCertificateStoreSelector هدایت می‌شوند: Location (‏cslCurrentUser یا cslLocalMachineStoreName (به‌طور پیش‌فرض 'MY'، فروشگاه شخصی)، یک Thumbprint با SHA-1، و یک پرچم AllowUI. اثر انگشت (thumbprint) در داخل نرمال‌سازی می‌شود، پس خط تیره یا فاصله‌هایی که مستقیم از رابط کاربری Certificate Manager کپی شده‌اند، پیش از انجام مقایسه حذف می‌شوند

var
  Selector: THPDFCertificateStoreSelector;
  Options: THPDFCMSSignOptions;
begin
  Selector := THPDFCertificateStoreSelector.Default;  // cslCurrentUser, store 'MY'
  Selector.Thumbprint := 'A1B2C3D4E5F6A7B8C9D0E1F2A3B4C5D6E7F8A9B0';
  Selector.AllowUI := False;

  Options := HPDFCMSDefaultOptions(palBaseline_B_B);
  if not HPDFSignPDFFileWithSystemCertificate('invoice.pdf',
    'invoice-signed.pdf', Selector, Options) then
    raise Exception.Create('Certificate-store signing failed');
end;

AllowUI = False اهمیتی بیش از ظاهرش دارد، چون مستقیماً به CRYPT_ACQUIRE_SILENT_FLAG نگاشت می‌شود، و ویندوز این را به معنای واقعی کلمه رعایت می‌کند: اگر کلید خصوصی گواهی منطبق روی یک smart card یا توکنی زندگی کند که به یک PIN prompt نیاز دارد که ویندوز از پیش آن را کش نکرده، CryptAcquireCertificatePrivateKey شکست می‌خورد به‌جای اینکه دیالوگی از یک فرآیند سرویس‌محور که شاید در پس‌زمینه باشد نمایش دهد. آن شکست بلند است، یک EHPDFCMSError که فوراً می‌بینید، اما به‌راحتی می‌توان آن را با «گواهی یافت نشد» اشتباه گرفت درحالی‌که علت واقعی، توکنی است که منتظر واردکردن PINی است که کسی قرار نیست تایپ کند

چرا CNG و CAPI بر سر ترتیب بایت اختلاف دارند؟

اینکه کدام بک‌اند پاسخ می‌دهد، حدس زدنی نیست: CryptAcquireCertificatePrivateKey این را مستقیماً از طریق یک پارامتر خروجی به نام KeySpec گزارش می‌دهد، و امضاکننده‌ی HotPDF دقیقاً بر اساس همین یک مقدار شاخه‌بندی می‌کند. کلید یک CNG Key Storage Provider با KeySpec برابر با مقدار نگهبان CERT_NCRYPT_KEY_SPEC ($FFFFFFFF) بازمی‌گردد؛ هر مقدار دیگری یعنی یک کلید CryptoAPI CSP سنتی. اغلب گواهی‌های شخصی صادرشده یا وارد‌شده روی یک نصب فعلی ویندوز به CNG می‌رسند، هرچند یک shim قدیمی CSP هنوز برای سازگاری وجود دارد، به همین دلیل HotPDF پیش از بررسی اینکه کدام مقدار برگشته، همراه با CRYPT_ACQUIRE_PREFER_NCRYPT_KEY_FLAG، پرچم CRYPT_ACQUIRE_ALLOW_NCRYPT_KEY_FLAG را نیز درخواست می‌کند

این دو بک‌اند فقط توابع متفاوتی صدا نمی‌زنند، NCryptSignHash در برابر یک کلید CNG، CryptSignHashA در برابر یک کلید CSP؛ آن‌ها امضای خام RSA را با ترتیب بایت معکوس هم برمی‌گردانند. خروجی CNG از پیش با آنچه PKCS#1 انتظار دارد مطابقت دارد: یک رشته اکتت big-endian، ابتدا پرارزش‌ترین بایت، دقیقاً همان چیزی که تبدیل I2OSP در RFC 8017 تولید می‌کند و همان چیزی که یک SignerInfo از CMS (طبق RFC 5652) در فیلد امضایش تحت ISO 32000-1 §12.8.3 نیاز دارد. در مقابل، CryptSignHash از CryptoAPI امضا را little-endian برمی‌گرداند، یک ویژگی مستندشده که ریشه‌اش به شیوه‌ی نمایش داخلی اعداد بزرگ در CSPهای کلاسیک برمی‌گردد. اگر در مسیر CAPI معکوس‌سازی را نادیده بگیرید، هر بایت امضا در جای اشتباه می‌نشیند؛ محاسبات RSA همچنان درست است، اما رشته اکتتی که یک تأییدکننده می‌خواند همان چیزی نیست که PKCS#1 تعریف کرده

// CryptSignHashA returns the RSA signature least-significant byte first;
// CMS/PKCS#7 (ISO 32000-1 Section 12.8.3) needs it most-significant byte first.
for I := 0 to (Length(Signature) div 2) - 1 do
begin
  Temp := Signature[I];
  Signature[I] := Signature[High(Signature) - I];
  Signature[High(Signature) - I] := Temp;
end;

در مورد یک callback امضاکننده سفارشی چطور؟

هر کسی که امضاکننده‌ی توکار cert-store در HotPDF را دور بزند، همان قاعده‌ی ترتیب بایت را به ارث می‌برد. HPDFCMSSignPDFStreamWithExternalSigner یک THPDFCMSSignDigestCallback می‌گیرد، یک closure از نوع reference to function(const SignedAttributesSHA256: TBytes): TBytes، برای امضا از طریق یک HSM، یک پشته میان‌افزار smart-card، یا هر چیز دیگری که گواهی‌ای نیست که فروشگاه ویندوز بتواند برایتان handle یک کلید صادر کند. هر بک‌اندی که پشت آن callback بنشیند، بایت‌هایی که برمی‌گرداند باید پیش از اینکه HotPDF آن‌ها را در ساختار CMS جا بدهد، به ترتیب big-endian باشند

Signer :=
  function(const SignedAttributesSHA256: TBytes): TBytes
  begin
    if UsesCngKeyStorageProvider then
      Result := SignWithMyCngKey(SignedAttributesSHA256)       // already big-endian
    else
      Result := ReverseBytes(SignWithMyLegacyToken(SignedAttributesSHA256));
  end;
HPDFCMSSignPDFStreamWithExternalSigner(InputStream, OutputStream,
  CertificateDER, Signer, Options);

ارزش دارد اینجا یک مرز را صریح بیان کنیم: دو مسیر امضای توکار HotPDF، CNG از طریق NCryptSignHash با padding از نوع PKCS#1، و CAPI از طریق CryptSignHashA، هر دو کلیدهای RSA را هدف می‌گیرند که یک digest ۳۲ بایتی SHA-256 را امضا می‌کنند. هیچ‌کدام قالب امضای ECDSA را مذاکره نمی‌کند. گواهی‌ای که کلید خصوصی‌اش مبتنی بر EC باشد، به یک امضاکننده نیاز دارد که خودتان در برابر HPDFCMSSignPDFStreamWithExternalSigner بنویسید، امضای ECDSA را همان‌طور که CMS انتظار دارد کدگذاری کنید نه اینکه فرض کنید یک رشته بایت RSA با طول ثابت است، پس انتظار نداشته باشید امضاکننده‌ی توکار cert-store کار درست را برای توکنی که با گواهی EC تدارک دیده شده انجام دهد

چرا C++Builder در لینک کردن CertOpenStore شکست می‌خورد؟

چون کتابخانه import پیش‌فرض C++Builder در RAD Studio، یعنی import32.lib، نه CertOpenStore را export می‌کند و نه پنج تابع همسایه‌اش را: CertEnumCertificatesInStore، CertGetCertificateContextProperty، CertFreeCertificateContext، CertCloseStore، و CryptAcquireCertificatePrivateKey. ساخت‌های Delphi هرگز این را نمی‌بینند، چون dcc32/dcc64 یک import استاتیک external 'crypt32.dll' را مستقیماً درون جدول import فایل PE حل می‌کنند. C++Builder فرق می‌کند: کامپایلر Delphi برای ساخت پکیج یک .obj با قالب OMF تولید می‌کند، ilink32 آن را لینک می‌کند، و در آن نقطه همان اعلان external صرفاً یک نماد حل‌نشده است که منتظر یک import library در خط فرمان است. اشاره‌کردن لینکر به پوشه psdk در Windows SDK، جایی که crypt32.lib کامل واقعاً هر شش نماد را export می‌کند، هم مشکل را حل نمی‌کند: ilink32 فقط import libraryهایی را لینک می‌کند که واقعاً در خط فرمانش نام برده شده‌اند، به‌طور پیش‌فرض import32.lib cp32mt.lib، و افزودن یک مسیر جستجو باعث نمی‌شود چیز اضافه‌ای از آن مسیر بکشد. اجرای tdump روی import32.lib این شکاف را مستقیماً تأیید می‌کند، صفر نتیجه برای CertOpenStore، در برابر شش نتیجه‌ی تمیز در crypt32.lib مربوط به SDK

HotPDF این را دقیقاً همان‌طور حل می‌کند که از پیش شمارش گواهی را در جای دیگری از کتابخانه مدیریت می‌کند: به‌جای اینکه این نمادها را از لینکر بخواهد، آن‌ها را در زمان اجرا بار می‌کند. یک رکورد داخلی به نام THPDFCryptoProcs یک handle برای crypt32.dll، یک handle برای advapi32.dll، و یازده فیلد اشاره‌گر تابع حمل می‌کند؛ LoadCryptoProcs هر دو DLL را بار می‌کند و دقیقاً یک‌بار، در ابتدای HPDFSignPDFStreamWithSystemCertificate، هر نقطه ورودی را با GetProcAddress حل می‌کند و اگر چیزی گم باشد، به‌جای شکست‌خوردن بعدتر با یک access violation در عمق فرایند امضا، فوراً EHPDFCMSError را raise می‌کند

type
  TCertOpenStoreFn = function(lpszStoreProvider: Pointer; dwEncodingType: DWORD;
    hCryptProv: NativeUInt; dwFlags: DWORD; pvPara: Pointer): HCERTSTORE; stdcall;
var
  Crypt32Handle: HMODULE;
  CertOpenStore: TCertOpenStoreFn;
begin
  Crypt32Handle := LoadLibrary('crypt32.dll');
  if Crypt32Handle = 0 then
    raise Exception.Create('crypt32.dll could not be loaded');
  @CertOpenStore := GetProcAddress(Crypt32Handle, 'CertOpenStore');
  // ... use CertOpenStore, then FreeLibrary(Crypt32Handle) when signing returns
end;

بارگذاری یک‌بار برای هر فراخوانی رخ می‌دهد نه به‌صورت تنبل درون هر تابع کمکی، چون closureای که بین CNG و CAPI انتخاب می‌کند جدول تابع بارشده را به‌صورت مقداری capture می‌کند و باید در طول کل فرایند امضا زنده بماند، از جمله callback به درون HPDFCMSSignPDFStreamWithExternalSigner؛ هر دو handle مربوط به DLL در بیرونی‌ترین بلوک finally پس از پایان امضا یا raise شدن خطا آزاد می‌شوند. هیچ‌کدام از این‌ها سطح عمومی را دست نمی‌زند: HPDFSignPDFStreamWithSystemCertificate، HPDFSignPDFFileWithSystemCertificate، و THPDFCertificateStoreSelector دقیقاً همان امضاهایی را حفظ می‌کنند که پیش از این داشتند، پس دریافت این رفع اشکال برای فراخوانندگان موجود صرفاً یک rebuild است، نه تغییر کد

این کار چه چیزی را پوشش نمی‌دهد

درست کردن ترتیب بایت و لینک C++Builder، یک SignerInfo از نوع CMS تولید می‌کند که یک اعتبارسنج می‌تواند آن را parse کند و امضایی که می‌تواند از نظر ریاضی بررسی کند؛ این هیچ چیزی درباره اینکه آیا آن اعتبارسنج باید به گواهی پشت آن اعتماد کند نمی‌گوید، چون ساخت زنجیره (chain building)، بررسی ابطال (revocation checking)، و سیاست مهر زمانی، دغدغه‌های جداگانه‌ای هستند که از طریق گزینه‌های CMS روی این لایه‌بندی می‌شوند، نه چیزی که درستی ترتیب بایت به‌رایگان تأمین کند. دو نکته‌ی نظافتی به‌اندازه‌ی خود رمزنگاری اهمیت دارند: PCCERT_CONTEXT که جستجوی گواهی برمی‌گرداند باید پیش از بسته‌شدن فروشگاه با CertFreeCertificateContext آزاد شود، و یک handle کلید CNG یا CSP دریافت‌شده، وقتی API گزارش می‌دهد که فراخواننده مالک آن است، باید از طریق فراخوانی خودِ همان بک‌اند آزاد شود، هرگز از طریق دیگری. اگر نتیجه‌ی svValidای که پس از همه‌ی این‌ها دریافت می‌کنید محدودتر از انتظارتان درآمد، مقاله‌ی بررسی امضای دیجیتال PDF دقیقاً توضیح می‌دهد که این پرچم چه چیزی را تضمین می‌کند و چه چیزی را نه. از آنجا که گواهی در تمام این مدت در قبالت ویندوز باقی می‌ماند، امضای مبتنی بر cert-store یک سطح حمله کامل را کنار می‌زند: هیچ فایل PKCS#12ای برای parse کردن و هیچ ASN.1ای برای پیمایش خودتان وجود ندارد، که این همان مسئله‌ای است که تقویت امنیتی PKCS#12 و ASN.1 در HotPDF برای مسیر امضای فایل PFX به آن می‌پردازد

امضای cert-store، امضای PFX، و callbackهای امضاکننده‌ی خارجی، سه در ورودی به یک خط لوله‌ی یکسان CMS/PKCS#7 درون کامپوننت PDF از HotPDF برای Delphi و C++Builder هستند، و انتخاب درِ درست عمدتاً به این برمی‌گردد که چه کسی اجازه دارد کلید خصوصی را نگه دارد: فرآیند خودتان، یک فایل PFX، یا خودِ ویندوز