مقاله فنی

چک آفلاین ابطال امضای PDF روی ویندوز در Delphi

PDFium VCL روی ویندوز چک ابطال امضای PDF را با اضافه کردن CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY به پاس ابطالِ CertGetCertificateChain آفلاین می‌کند، چون پرچم cache-only که برای ساختن زنجیره استفاده می‌شود اصلاً بازیابی CRL یا OCSP را پوشش نمی‌دهد. از نسخهٔ v3.119.1 یک فراخوان آفلاین از نوع ValidatePadesTrust دیگر سراغ شبکه نمی‌رود، و یک نتیجهٔ تمیز به شواهد ابطال واقعی به-ازای-هر-گواهی نیاز دارد. بقیهٔ این پست دربارهٔ این است که چرا هر دو نیمهٔ این جمله نیاز به fix داشتند

سناریویی که مشکل را لو می‌دهد معمولی است. یک سرویس اعتبارسنجی روی یک هاست ویندوزی قفل‌شده اجرا می‌شود، TPadesTrustValidationOptions.NetworkPolicy روی ptnpOffline است (که پیش‌فرض هم هست)، و اپراتور انتظار دارد هر جوابی از کش محلی گواهی بیاید. بعد کسی درخواست‌های خروجی به یک نقطهٔ توزیع CA را در لاگ فایروال می‌بیند، یا یک batch job که روی هر امضا تا سقف کامل UrlRetrievalTimeoutMs یعنی 15000 میلی‌ثانیه معطل می‌شود. هیچ‌جای کد شبکه نخواسته بود. ویندوز خودش رفت

چرا یک ساخت زنجیرهٔ آفلاین همچنان روی ویندوز CRL می‌گیرد؟

چون CERT_CHAIN_CACHE_ONLY_URL_RETRIEVAL فقط بازیابی URLی را محدود می‌کند که ساخت زنجیره انجام می‌دهد: گرفتن issuerهای AIA، آپدیت‌های root و CTL. مستندات مایکروسافت برای CertGetCertificateChain صراحتاً می‌گوید این پرچم به چک ابطال اعمال نمی‌شود. ابطال سوییچ خودش را دارد، CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY ($80000000)، و بدون آن providerهای ابطال آزادند یک CRL دانلود کنند یا یک درخواست OCSP بفرستند، حتی وقتی فراخوان اطراف آفلاین به نظر می‌رسد. PDFium VCL حالا هر وقت OnlineRetrieval برابر False باشد این پرچم را در پاس ابطال OR می‌کند، روی پرچم‌های زنجیره و CERT_CHAIN_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT و CERT_CHAIN_REVOCATION_ACCUMULATIVE_TIMEOUT. این برای بیش از تأخیر مهم است: یک درخواست OCSP به responder می‌گوید شما دارید به کدام گواهی نگاه می‌کنید، و دقیقاً همان چیزی است که یک validator هوایی قرار است ازش اجتناب کند

دو سوییچ مستقل اعتبارسنجی آفلاین امضای PDF را روی ویندوز نگهبانی می‌کنند: CERT_CHAIN_CACHE_ONLY_URL_RETRIEVAL فقط ساخت زنجیره مثل گرفتن issuer و root را محدود می‌کند، در حالی که ابطال به CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY نیاز دارد، چون بدون آن providerها همچنان CRL دانلود و درخواست OCSP می‌فرستند که لو می‌دهد کدام گواهی دارد اعتبارسنجی می‌شود
PDFium VCL هر وقت OnlineRetrieval برابر False باشد پرچم cache-only ابطال را در پاس ابطال OR می‌کند، پس یک اعتبارسنجی اعتماد با ptnpOffline برای هر دو پاس از شبکه دور می‌ماند
uses
  PDFium, FPdfCrypto, FPdfPades;

var
  Options: TPadesTrustValidationOptions;
  Report: TPadesValidationResult;
begin
  Options := TPadesTrustValidationOptions.Default; // ptnpOffline، 15000 میلی‌ثانیه
  Options.CheckRevocation := True;                 // پیش‌فرض False
  Options.CheckTimeStamps := True;
  // آفلاین حالا برای ابطال هم آفلاین یعنی: فقط پاسخ‌های کش‌شدهٔ CRL و OCSP،
  // و هیچ checkpoint از نوع pcvstOnlineRetrieval بالا نمی‌آید
  Report := Pdf.ValidatePadesTrust(Options);
end;

دو ساخت زنجیره، دو فیلد خطا

بک‌اند ویندوز زنجیره را دو بار می‌سازد، و هر ساخت حالا اسلات خطای خودش را دارد. فراخوان اول CertGetCertificateChain بدون پرچم‌های ابطال اجرا می‌شود و CertVerifyCertificateChainPolicy را با سیاست پایه تغذیه می‌کند، که TrustStatus و TrustError تولید می‌کند. فراخوان دوم پرچم‌های ابطال را اضافه می‌کند. قبل از v3.119.1 یک شکست در آن فراخوان دوم GetLastError را داخل TrustError می‌نوشت، پس زنجیره‌ای که همین حالا معتبر تأیید شده بود می‌توانست نامعتبر به نظر برسد چون یک provider ابطال سرفه کرده بود. fix بلافاصله GetLastError را می‌خواند و در TPdfCmsVerifyResult.RevocationError ذخیره می‌کند و حکم پاس اول را دست نمی‌زند. و مقدار True برگشتی از فراخوان دوم هم موفقیت حساب نمی‌شود؛ فقط یعنی ویندوز یک chain context ارزش بازرسی تحویل داده

یک ماسک خطای اعتماد صفر واقعاً چه چیزی را اثبات می‌کند؟

به‌تنهایی هیچ. یک TrustStatus.dwErrorStatus تجمیعی صفر بعد از پاس ابطال یعنی هیچ بیت خطایی بالا نیامده، و زنجیره‌ای که هیچ المانی اصلاً اطلاعات ابطال نداشته باشد می‌تواند دقیقاً همین را تولید کند. کد قبلی «نه بیت revoked و نه بیت unknown و نه بیت offline» را مستقیم به معتبر نگاشت می‌کرد، که همان راه کلاسیک است که یک validator یک گواهی چک‌نشده را تمیز گزارش می‌کند. روتین جدید ReadWinRevocationEvidence هر simple chain و هر المان را می‌پیماید، ساختارهایی را که cbSize آن‌ها برای خواندن امن خیلی کوچک است رد می‌کند، و فقط وقتی موفقیت گزارش می‌کند که دست‌کم یک المان غیر-ریشه وجود داشته باشد و هر چنین المانی یک CERT_REVOCATION_INFO حمل کند که dwRevocationResult آن صفر باشد

پیمایش شواهد که ReadWinRevocationEvidence به‌ازای هر المان زنجیره در Delphi روی ویندوز انجام می‌دهد: pRevocationInfo باید حاضر باشد، cbSize باید به اندازهٔ کافی برای خواندن بزرگ باشد، dwRevocationResult باید صفر باشد، و المان آخر فقط وقتی کنار گذاشته می‌شود که self-signed یا مورد اعتماد CA علامت خورده باشد، پس یک ماسک خطای اعتماد صفر دیگر نمی‌تواند گواهی چک‌نشده را پنهان کند
یک حکم تمیز دست‌کم یک المان غیر-ریشه و یک جواب از هر المان مورد نیاز می‌خواهد، و RevocationError مقدار خام DWORD مربوط به provider مثل CRYPT_E_REVOKED را نگه می‌دارد
// فشرده‌شده از پیمایش شواهد: یک المان فقط وقتی حساب می‌شود که
// provider ابطال واقعاً برایش جواب داده باشد
for J := 0 to ElementCount - 1 do
begin
  Element := Elements[J];
  ExcludedRoot := (J = ElementCount - 1) and
    ((Element^.TrustStatus.dwInfoStatus and
      (CERT_TRUST_IS_SELF_SIGNED or CERT_TRUST_IS_CA_TRUSTED)) <> 0);
  InfoPresent := (Element^.pRevocationInfo <> nil) and
    (Element^.pRevocationInfo^.cbSize >= SizeOf(TCERT_REVOCATION_INFO));
  if not ExcludedRoot then
  begin
    Inc(RequiredCount);
    if not InfoPresent or
      (Element^.pRevocationInfo^.dwRevocationResult <> 0) then
      Complete := False;
  end;
end;
Complete := Complete and (RequiredCount > 0); // زنجیرهٔ فقط-ریشه هیچ چیزی اثبات نمی‌کند

نتیجهٔ provider خام نگه داشته می‌شود. RevocationError مقدار DWORD مربوط به dwRevocationResult را دقیقاً همان‌طور که provider برگردانده نگه می‌دارد، در صورت وجود خطای المان revoked همان را ترجیح می‌دهد (CRYPT_E_REVOKED برابر $80092010 است)، و بیت‌ماسک اعتماد هرگز به‌شکل کد خطای بومی لباس نمی‌پوشد. نگاشت به TPdfCmsRevocationReason عامدانه درشت است: pcrrCertificateRevoked با pcvsInvalid برای ابطال صریح، pcrrChainUntrusted وقتی زنجیره به دلایل بی‌ربط به ابطال شکست خورده، و pcrrUnknown برای هر چیز دیگر. ویندوز ممکن است به‌جای CRL سراغ OCSP رفته باشد، پس یک نتیجهٔ آفلاین یا ناشناخته به pcrrCrlExpired ترجمه نمی‌شود. بک‌اند راستی‌آزمایی CMS بر پایهٔ OpenSSL می‌تواند این تمایزهای مخصوص CRL را بدهد چون فقط CRLهایی را ارزیابی می‌کند که به‌دستش می‌دهید، در حالی که بک‌اند SecTrust در macOS فیلدها را روی pcrrNone و صفر می‌گذارد، که یعنی «هیچ تشخیص دقیقی نیست»، نه «پاس شد»

طرد کردن ریشه کجا تمام می‌شود؟

CERT_CHAIN_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT به‌طور مشروع لنگر را skip می‌کند، چون هیچ‌کس CRLی منتشر نمی‌کند که یک ریشه را در برابر خودش ابطال کند. تله این است که تصمیم بگیرید کدام المان ریشه است. PDFium VCL آخرین المان یک simple chain را فقط وقتی طرد می‌کند که dwInfoStatus آن را self-signed ($00000008) یا صراحتاً مورد اعتماد CA ($00004000) علامت زده باشد. یک هاست آفلاین اغلب نمی‌تواند issuer گمشده را بگیرد، پس زنجیره در یک intermediate تمام می‌شود؛ ریشه گرفتن المان آخر آن زنجیرهٔ ناقص بی‌سروصدا همان گواهی‌ای را از فهرست مورد نیاز حذف می‌کند که احتمال نبودن وضعیت ابطالش در کش از همه بیشتر است. آن المان در مجموعهٔ مورد نیاز می‌ماند، جوابی از provider ندارد، و نتیجه pcvsIndeterminate می‌ماند

نتایج ابطال امضا و تایم‌استمپ چطور از هم جدا می‌مانند؟

به‌عنوان فیلدهای جدا که هرگز روی هم نمی‌نویسند. validator مربوط به PAdES از CMS جدا شدهٔ امضای سند و CMS پیوستهٔ توکن تایم‌استمپ RFC 3161 در دو فراخوان مستقل راستی‌آزمایی می‌کند، و v3.119.0 به هر یک تشخیص‌های خودش را روی TPadesSignatureValidation داد: RevocationReason و NativeRevocationError برای امضاکننده، و TimeStampRevocationReason و NativeTimeStampRevocationError برای TSA. پس یک گواهی TSA ابطال‌شده نمی‌تواند خودش را امضاکنندهٔ ابطال‌شده جا بزند، و یک شکست تایم‌استمپ نتیجهٔ یکپارچگی‌ای را که از قبل تثبیت شده پاک نمی‌کند. وقتی CheckRevocation برابر False است، یا اعتبارسنجی هرگز به آن مرحله نرسیده، فیلدها روی pcrrNone و 0 می‌مانند، پس همیشه آن‌ها را کنار RevocationStatus و TimeStampRevocationStatus بخوانید

ابطال امضا و تایم‌استمپ در PDFium VCL از هم جدا می‌مانند: CMS جدا شدهٔ امضای سند فیلدهای RevocationReason و NativeRevocationError را پر می‌کند، CMS پیوستهٔ توکن RFC 3161 فیلدهای TimeStampRevocationReason و NativeTimeStampRevocationError را پر می‌کند، و مراحل چک‌نشده pcrrNone و صفر را کنار فیلدهای وضعیتشان می‌گذارند
پس یک گواهی TSA ابطال‌شده نمی‌تواند خودش را امضاکنندهٔ ابطال‌شده جا بزند، و یک شکست تایم‌استمپ هرگز نتیجهٔ یکپارچگی‌ای را که راستی‌آزمایی امضا تثبیت کرده پاک نمی‌کند
for I := 0 to High(Report.Signatures) do
begin
  S := Report.Signatures[I];
  case S.RevocationStatus of
    pcsInvalid:
      Log(Format('sig %d: signer revoked, provider 0x%.8x',
        [I, S.NativeRevocationError]));
    pcsIndeterminate:
      Log(Format('sig %d: revocation unknown, reason %d, provider 0x%.8x',
        [I, Ord(S.RevocationReason), S.NativeRevocationError]));
    pcsNotChecked:
      Log(Format('sig %d: revocation not checked', [I]));
  end;
  if S.TimeStampRevocationStatus = pcsIndeterminate then
    Log(Format('sig %d: TSA revocation unknown, provider 0x%.8x',
      [I, S.NativeTimeStampRevocationError]));
end;

گزارش شواهد همان قاعده را دنبال می‌کند. export مربوط به CSV ستون‌های revocationReason و nativeRevocationError و ستون‌های تایم‌استمپ تا nativeTimeStampRevocationError را در انتهای ترتیب ستون‌های موجود append می‌کند، پس پارسرهای قدیمی سر کارشان می‌مانند، و export مربوط به JSON فیلدهای متناظر را اضافه می‌کند بدون اینکه معنای فیلدهای قدیمی را عوض کند. اگر اعتبارسنجی آفلاین مدام indeterminate برمی‌گردد، fix ماندگار بالادستی است: مواد اعتبارسنجی را موقع امضا جمع کنید، همان‌طور که در امضاهای بلندمدت PDF با تایم‌استمپ‌های RFC 3161 و DSS توصیف شده، به‌جای اینکه امیدوار باشید ماشین راستی‌آزمایی کش گرمی داشته باشد

ماتریس تست چه چیزی را اثبات می‌کند و چه چیزی را نه؟

ماتریس راستی‌آزمایی ویندوز 30 سناریوی کنترل‌شدهٔ API زنجیره و یک smoke آفلاین واقعی CMS را روی هر تارگت Delphi و FPC در Win32 و Win64 پاس کرد. smoke واقعی یک امضای معتبر را زیر یک CA خصوصی غیرمورد-اعتماد verify می‌کند، در حالی که نتایج تمیز و صریحاً-ابطال‌شده از پاسخ‌های stubشدهٔ CertGetCertificateChain می‌آیند نه از trust anchorهای نصب‌شده یا بازیابی زنده. این یک مرز صادقانه است که ارزش بیانش را دارد: هندل کردن پرچم‌ها و جداسازی خطا و پیمایش شواهد پین شده‌اند، ولی اینکه کش ابطال یک ماشین مشخص در یک روز مشخص چه دارد همچنان کار ویندوز است، و یک کش خالی حالا درست به «نامعلوم» می‌رسد به‌جای یک درخواست شبکه یا یک «معتبر» دروغین

هندل ابطال آفلاین و تشخیص‌های به-ازای-هر-فیلد و exportهای شواهد بخشی از API اعتبارسنجی امضای PDF در PDFium VCL برای Delphi و C++Builder هستند، در کنار بک‌اندهای OpenSSL و macOS برای استقرارهای چند-پلتفرمی