مقاله فنی

وریفای امضاهای PDF با OpenSSL در PDFium VCL

PDFium VCL وریفیکیشن CMS را به‌شکل یک بک‌اند جایگزین‌پذیر پشت اینترفیس IPdfCmsVerifier مدیریت می‌کند، پس اعتبارسنج PAdES می‌تواند روی ویندوز از طریق CryptoAPI، روی macOS از طریق Keychain، و هرجا OpenSSL حاضر باشد از طریق ConfigureSslCmsVerifier اجرا شود. اینترفیس کوچک است. سه رفتار OpenSSL زیر آن اگر ساده‌لوحانه پیاده‌اش کنید جواب‌های غلطِ مطمئن تولید می‌کنند

انگیزه از وقتی روشن می‌شود که یک اپلیکیشن دلفی از ویندوز بیرون بزند. اعتبارسنجی امضا یکی از آن مناطق کمیاب است که پشته crypto پلتفرم در آن جزئیات پیاده‌سازی نیست: تصمیم می‌گیرد کدام گواهی‌ها trusted باشند، کدام الگوریتم‌ها وجود دارند و ابطال چه معنایی دارد. یکی را hard-code کنید و کد پورت نمی‌شود. بد abstractش کنید و هر پلتفرم جوابی با شکلی متفاوت گزارش می‌کند که caller نمی‌تواند مقایسه‌شان کند

abstraction واقعاً قرار است چه چیزی را حمل کند

دو شکل وریفیکیشن و سه حکم مستقل. امضای PDF از جنس detached است: محتوای امضاشده دو بازه بایتی دو طرف سوراخ /Contents هستند، پس VerifyDetached دو سگمنت می‌گیرد نه یک بافر. توکن timestamp از جنس attached است و محتوایش را خودش حمل می‌کند، پس VerifyAttached فقط DER را می‌گیرد

نتیجه به سه status می‌شکند چون به سه سؤال متفاوت جواب می‌دهند و می‌توانند ناخوان باشند. SignatureStatus می‌گوید آیا بایت‌ها با کلید داخل گواهی signer امضا شده‌اند. TrustStatus می‌گوید آیا آن گواهی به چیزی که به آن اعتماد دارید زنجیر می‌شود. RevocationStatus می‌گوید آیا گواهی در زمان مربوط هنوز معتبر بوده. سندی با امضای از نظر ریاضی کامل از گواهی‌ای که هرگز نشنیده‌ایدش valid است، untrusted است و unknown، و له‌کردن اینها در یک boolean واحد همان چیزی است که اعتبارسنج‌ها را به دروغ‌گفتن به کاربر می‌کشاند

uses
  FPdfCrypto, FPdfCryptoSsl;

var
  Options: TPdfCmsVerifyOptions;
begin
  if not SslAvailable then
    raise Exception.Create('libcrypto not usable: ' + SslMissingSymbols);

  ConfigureSslTrustAnchors(LoadCorporateRoots);   // DER، می‌تواند خالی باشد
  ConfigureSslCrls(LoadFreshCrls);                // DER، می‌تواند خالی باشد
  ConfigureSslCmsVerifier;                        // بک‌اند را نصب می‌کند

  Writeln('backend  : ', PadesCmsVerificationBackendName);
  Writeln('library  : ', SslLibraryPath, ' ', SslLibraryVersion);
  Writeln('ABI      : ', SslAbiLayout);           // ulong=<n> long=<n>

  Options := TPdfCmsVerifyOptions.Default;
  Options.CheckRevocation := True;
  Options.CollectChainCertificates := True;
end;

SslAbiLayout شبیه یک جزئیات عجیب به نظر می‌رسد و نیست. هر کد خطای OpenSSL و هر پرچم store از مرز به‌شکل یک unsigned long سی رد می‌شود، که روی ویندوز چهار بایت و روی Linux و macOS هشت بایت است. آن را به‌شکل یک تایپ ثابت 32 بیتی اعلان کنید و کد روی ویندوز کار می‌کند، بعد روی LP64 بی‌سروصدا نصف یک مقدار را می‌خواند. گزارش‌دادن عرض‌های مفروض به‌شکل رشته‌ای که در یک تست می‌شود رویش assert زد یک رده کامل از ABI drift پلتفرم را به یک چک یک‌خطی تبدیل می‌کند. هر کسی که همان مسئله را با CK_ULONG در یک بایندینگ PKCS#11 سر و کله زده فوراً می‌شناسدش؛ آن قصه در struct packing و عرض CK_ULONG در PKCS#11 است

چرا گذر دوم وریفیکیشن محتوای خالی می‌بیند؟

چون CMS_verify محتوای detached را در BIO تا انتهای فایل می‌خواند، و BIOای که خوانده شده برایتان rewind نمی‌شود. وریفای در دو گذر یک طراحی منطقی است، اول امضای رمزنگارانه به‌تنهایی با ارزیابی زنجیره خاموش، بعد ارزیابی کامل، و اگر هر دو گذر یک BIO مشترک داشته باشند به شکلی به‌طرزی غیرعادی فریبنده شکست می‌خورد

گذر دوم صفر بایت محتوا می‌گیرد. در حالت detached این یک خطا نیست، چون بافر محتوای خالی یک ورودی قانونی است. digest به‌سادگی مچ نمی‌شود، و شکست به‌شکل شکست ساختن زنجیره خودش را نشان می‌دهد نه شکست محتوا، که شما را می‌فرستد سراغ بازرسی گواهی‌ها و trust storeها در حالی که مسئله واقعی یک موقعیت stream است. برای هر گذر با BIO_new_mem_buf حافظه BIO را از نو بسازید. هزینه‌اش یک allocation است و امکان ماجرا را کلاً حذف می‌کند

پرچم no-verify چه چیزی را سرکوب می‌کند و چه چیزی را نه

CMS_NO_SIGNER_CERT_VERIFY ارزیابی زنجیره را سرکوب می‌کند نه lookup گواهی signer را. در درون OpenSSL گواهی‌های signer را resolve و الصاق می‌کند قبل از اینکه سراغ پرچم برود، پس بعد از یک گذر اول که آن پرچم را حمل می‌کند signer از قبل در دسترس است و identifierهای الگوریتمش را می‌شود بلافاصله خواند. لازم نیست یک وریفیکیشن کامل دوم فقط برای گرفتن گواهی signer اجرا کنید، که همان چیزی است که نام پرچم شما را وسوسه می‌کند فرضش کنید

یک قاعده مالکیت هم همراهش می‌آید. ارجاع signer متعلق به ساختار CMS است و نباید مستقل آزاد شود. تا وقتی ساختار زنده است معتبر است، و آزاد کردنش خرابی‌ای تولید می‌کند که علامتش کلاً جای دیگری ظاهر می‌شود، معمولاً حین cleanup یک آبجکت بی‌ربط

چرا روشن‌کردن چک CRL همه امضاها را رد می‌کند؟

چون OpenSSL فقط CRLها را در برابر چیزی که store از قبل دارد چک می‌کند و خودش هیچ چیزی fetch نمی‌کند. دنبال CRL distribution pointها نمی‌رود و OCSP حرف نمی‌زند. X509_V_FLAG_CRL_CHECK را روی storeای بدون هیچ CRL ست کنید و هر زنجیره‌ای با ناتوانی در گرفتن CRL گواهی شکست می‌خورد. نتیجه شبیه این است که چک ابطال کار می‌کند و مسئله پیدا می‌کند. چیزی که هست این است که چک ابطال اصلاً اجرا نشده

پس بک‌اند پرچم را فقط وقتی ست می‌کند که ConfigureSslCrls واقعاً دست‌کم یک CRL داده باشد. بدون آن، RevocationStatus به‌شکل pcvsUnsupported برمی‌گردد، که یک اظهار صادقانه است که به سؤال جواب داده نشد. به همان دلیل OnlineRetrieval روی این بک‌اند اثری ندارد و هیچ چک‌پوینت pcvstOnlineRetrieval صادر نمی‌شود: هیچ مسیر fetchingای وجود ندارد که پیشرفت از آن گزارش شود

نمودار وریفایر CMS OpenSSL در PDFium VCL با سه تله: یک BIO محتوای مشترک که تا انتهای فایل خوانده شده گذر دوم وریفیکیشن را با صفر بایت تنها می‌گذارد، CMS_NO_SIGNER_CERT_VERIFY ارزیابی زنجیره را سرکوب می‌کند اما lookup گواهی signer را نه، و چک CRL روی store خالی هر زنجیره‌ای را رد می‌کند بدون اینکه ابطال اصلاً اجرا شود
هر تله یک حکم غلطِ مطمئن تولید می‌کند: یک موقعیت stream خودش را به‌شکل شکست اعتماد جا می‌زند، پرچم no-verify کمتر از آنچه نامش می‌گوید سرکوب می‌کند، و ابطالِ هرگز-اجرا-نشده شبیه ابطالی به نظر می‌رسد که مسئله پیدا کرده

این یک موضع طراحی است که ارزش دفاع از آن در حالت کلی را دارد. اعتبارسنجی‌ای که نمی‌تواند ابطال را چک کند باید همین را بگوید. گزارش‌دادن گواهی چک‌نشده به‌شکل ابطال‌نشده رایج‌ترین راه گمراه‌کردن کاربران توسط ابزارهای اعتبارسنجی امضا است، و دقیقاً از آن رده سردرگمی است که در چرا اعتبارسنج‌ها امضاهای PAdES را رد می‌کنند کاویده شده

// چک‌پوینت‌ها به یک UI اجازه می‌دهند نشان بدهد کدام مرحله در جریان است و
// بگویند یک بک‌اند واقعاً کدام مراحل را اجرا می‌کند
type
  TSignatureProbe = class
    procedure Checkpoint(Stage: TPdfCmsVerifyStage);
  end;

procedure TSignatureProbe.Checkpoint(Stage: TPdfCmsVerifyStage);
begin
  case Stage of
    pcvstCryptographicSignature: Status('checking the signature');
    pcvstChainBuild:             Status('building the certificate chain');
    pcvstOnlineRetrieval:        Status('fetching validation data');
    pcvstRevocationCheck:        Status('checking revocation');
  end;
end;

// سه حکم را جداگانه بخوانید؛ مجازند با هم ناخوان باشند
if Result.SignatureStatus = pcvsValid then
  case Result.TrustStatus of
    pcvsValid:         Report('signed and trusted');
    pcvsInvalid:       Report('signed, chain rejected');
    pcvsUnsupported,
    pcvsIndeterminate: Report('signed, trust not established');
  end;
if Result.RevocationStatus = pcvsUnsupported then
  Report('revocation was not checked on this backend');

باند شدن به کتابخانه‌ای که نمی‌توانید pinش کنید

OpenSSL بین 1.0 و 1.1 accessorهای stack خودش را rename کرد، پس همان تابع منطقی بسته به بیلدی که هاست اتفاقاً دارد دو نام اکسپورت ممکن دارد. بایندینگ اول نام جدیدتر را resolve می‌کند و به قدیمی‌تر برمی‌گردد، و فقط وقتی هیچ‌کدام resolve نشد سیمبول غایب را ثبت می‌کند. این شکل درست برای هر بایندینگ داینامیکی به کتابخانه‌ای است که شما ship نمی‌کنید: نام‌های فعلی را ترجیح بده، نام‌های تاریخی را تحمل کن، و فقط غیبت واقعی را گزارش کن

SslMissingSymbols چیزی است که یک لود شکست‌خورده را به یک رویداد قابل‌تشخیص تبدیل می‌کند. نتیجه غیرخالی روی هاستی که واضح است libcrypto رویش نصب است یعنی نسخه نصب‌شده از APIای که این بیلد هدفش است قدیمی‌تر است، که یک گفت‌وگوی پشتیبانی کاملاً متفاوت با یک کتابخانه غایب است. ConfigureSslLibraryPath مورد رایج دیگر را پوشش می‌دهد، هاستی با چند بیلد OpenSSL که آنی که روی مسیر جست‌وجوی پیش‌فرض است آنی نیست که می‌خواهید

انتخاب یک بک‌اند به‌ازای هر پلتفرم

چیدمان عملی این است که در استارتاپ انتخاب کنید و ثبت کنید کدام جواب داد. روی ویندوز، بک‌اند پلتفرم با certificate storeهایی که یک سازمان از قبل مدیریت می‌کند یکپارچه می‌شود، که معمولاً همان چیزی است که می‌خواهید. روی macOS بک‌اند Keychain با همان استدلال جور درمی‌آید و در وریفای امضاها با SecTrust روی macOS توصیف شده. OpenSSL گزینه پرتابل است، و همچنین انتخاب درست وقتی است که به یک سیاست اعتبارسنجی یکسان بین پلتفرم‌ها نیاز دارید نه یکی که از trust store هر پلتفرم پیروی کند

نمودار abstraction اینترفیس IPdfCmsVerifier در PDFium VCL که VerifyDetached را روی دو بازه بایتی دو طرف سوراخ Contents و VerifyAttached را برای توکن‌های timestamp حمل می‌کند، سه حکم مستقل SignatureStatus و TrustStatus و RevocationStatus، و بک‌اندهای به‌ازای هر پلتفرم که در استارتاپ از طریق CryptoAPI یا SecTrust یا ConfigureSslCmsVerifier انتخاب می‌شوند
اینترفیس دو شکل وریفیکیشن و سه حکم را حمل می‌کند چون به سؤال‌های متفاوتی جواب می‌دهند و می‌توانند ناخوان باشند، و بک‌اند نصب‌شده کنار هر حکم ثبت می‌شود تا نتیجه‌های ذخیره‌شده قابل بازتولید باشند

هرکدام را که نصب می‌کنید، PadesCmsVerificationBackendName را کنار هر حکمی که ثبت می‌کنید لاگ کنید. یک نتیجه اعتبارسنجی ذخیره‌شده بدون بک‌اندی که تولیدش کرده بعداً قابل بازتولید نیست، چون سه مقدار status بسته به اینکه کدام پشته جواب داده معانی ظریفاً متفاوتی دارند. لایه بازرسی امضا روی همه اینها، شامل اینکه سطوح PAdES چطور گزارش می‌شوند، در بازرسی امضاهای دیجیتال PDF و سطوح PAdES پوشش داده شده

همه‌اش به‌شکل سورس همراه PDFium Delphi component عرضه می‌شود، که اینجا بیشتر از همیشه اهمیت دارد: برای یک validator امضا، توانایی خواندن دقیق اینکه یک بک‌اند کدام پرچم‌ها را ست می‌کند و کدام چک‌ها را رد می‌کند یک مزیت اضافه نیست، تنها راه فهمیدن این است که یک تیک سبز در اپلیکیشن شما واقعاً چه ادعایی می‌کند