مقاله فنی

اعتبارسنجی امضای PDF در macOS با SecTrust و Delphi

PDFium Delphi Component امضاهای PDF را در macOS از طریق TPdfKeychainCmsVerifier verify می‌کند؛ این backend مربوط به CMS روی Apple CMSDecoder و SecTrust بنا شده است، نه روی CMSی که با دست parse شده باشد. ConfigureKeychainCmsVerifier آن را install می‌کند و یک call به CMSDecoderCopySignerStatus هم‌زمان verdict امضا، handle مربوط به SecTrust و code نتیجه certificate را برمی‌گرداند؛ دقیقاً همان جفت columnهایی که TPdfCmsVerifyResult از قبل در Windows حمل می‌کرد

سناریوی محرک این کار معمولی و رایج است. یک build از نوع Lazarus برای document archive روی Mac اجرا می‌شود، contract امضاشده را باز می‌کند و همه signatureها با pcsUnsupported برمی‌گردند. file مشکلی ندارد. verification امضا خارج از Windows اصلاً backend نداشت و PAdES validator در نبود backend از حدس زدن خودداری می‌کرد. نسخه 3.111.0 از PDFiumPas این seam را با IPdfCmsVerifier و ConfigurePadesCmsVerifier باز کرد و نسخه 3.113.0 آن را در macOS پر کرد. بخش جالب این port plumbing نیست، سه جایی است که API اپل شکل مشابهی با API Windows ندارد

چرا یک امضای PDF دو byte range را پوشش می‌دهد؟

چون signature نمی‌تواند byteهایی را که خودش را درون آن‌ها نگه می‌دارند پوشش دهد. ISO 32000-1 §12.8.1، blob مربوط به CMS SignedData را در string /Contents از signature dictionary قرار می‌دهد و extent امضاشده را با /ByteRange توصیف می‌کند؛ مجموعه‌ای از جفت‌های offset و length که همه چیز در دو طرف آن hole را پوشش می‌دهند. در هر platform، دو segment و یک gap در وسط داریم

platformها درباره اینکه این segmentها چگونه به crypto layer برسند اختلاف دارند و همین اختلاف memory هزینه دارد. در Windows، CryptVerifyDetachedMessageSignature arrayای از pointer و length می‌پذیرد؛ بنابراین هر دو span همان‌طور که در buffer هستند وارد می‌شوند و duplicate نمی‌شوند. Apple CMSDecoderSetDetachedContent یک CFData می‌پذیرد و form چندsegmentی ندارد، پس backend macOS پیش از decode، دو range را به buffer contiguous متصل می‌کند. این کار یک copy کامل دوم از byteهای امضاشده است. روی archive اسکن‌شده 400 MB، peak memory واقعی ایجاد می‌کند، با document scale می‌شود نه با signature و API جایگزینی برای آن وجود ندارد. batch worker را متناسب اندازه کنید، نه اینکه این موضوع را روی machine customer کشف کنید

یک call دو column از TPdfCmsVerifyResult را پر می‌کند

CMSDecoderCopySignerStatus برای یک entry point از Security.framework غیرمعمولاً generous است: یک call، status مربوط به signer، یک SecTrustRef برای chainی که ساخته و یک OSStatus برای certificate evaluation برمی‌گرداند. این valueها مستقیم در recordی قرار می‌گیرند که PAdES validator از قبل مصرف می‌کند؛ status مربوط به signer به SignatureStatus، نتیجه certificate به TrustStatus و valueهای خام به SignatureError و TrustError می‌روند تا support ticket بتواند به‌جای adjective یک number نقل کند. callerها هرگز مستقیماً با IPdfCmsVerifier کار نمی‌کنند؛ ValidatePadesCompliance و ValidatePadesTrust هر verification را از backend نصب‌شده عبور می‌دهند، پس codeی که TPadesSignatureValidation را می‌خواند در هر دو platform byte-for-byte یکسان است؛ همان‌طور که در راهنمای بازرسی signature dictionary و سطح‌های PAdES در Delphi توضیح داده شده است

uses
  FPdfCrypto, FPdfCryptoMac, FPdfPades;

procedure InstallMacVerifier;
begin
  // symbolهای framework مربوط به signing و verification جدا resolve می‌شوند؛
  // پس ممکن است یکی حاضر باشد و دیگری نه
  if not KeychainVerificationAvailable then
    raise Exception.CreateFmt('Security.framework symbols missing: %s',
      [KeychainMissingSymbols]);

  ConfigureKeychainCmsVerifier;

  // PadesCmsVerificationBackendName حالا پاسخ می‌دهد: 'macOS Security.framework'
  if not PadesCmsVerificationAvailable then
    raise Exception.Create('No CMS verification backend is installed');
end;

چرا kCMSSignerInvalidCert signature معتبر گزارش می‌کند؟

چون Apple معنای این value را محدودتر از چیزی تعریف کرده که name آن القا می‌کند: خود signature verify شده و فقط chain مربوط به certificate برقرار نشده است. بنابراین TPdfKeychainCmsVerifier، kCMSSignerInvalidCert را در column مربوط به SignatureStatus به pcvsValid map می‌کند و مشکل certificate را از طریق TrustStatus بیرون می‌گذارد، جایی که مشکل chain به آن تعلق دارد. اگر آن را در verdict signature fold کنید، component به operator می‌گوید document دست‌کاری‌نشده تغییر کرده است؛ بدترین false alarmی که signature validator می‌تواند ایجاد کند همین است

function MapSignerStatus(Status: LongWord): TPdfCmsVerifyStatus;
begin
  case Status of
  kCMSSignerValid:
    Result:= pcvsValid;
  // signature verify شده و فقط chain verify نشده است؛ trust status
  // آن را جداگانه report می‌کند
  kCMSSignerInvalidCert:
    Result:= pcvsValid;
  kCMSSignerInvalidSignature, kCMSSignerUnsigned:
    Result:= pcvsInvalid;
  else
    Result:= pcvsIndeterminate;
  end;
end;

دو status را به‌صورت یک ordered pair بخوانید تا reporting logic خودش روشن شود. SignatureStatus = pcvsValid همراه TrustStatus = pcvsInvalid documentی را توصیف می‌کند که byteهایش سالم‌اند اما issuer آن را این Mac خاص trust نمی‌کند: anchorی که در Keychain گم است، intermediate منقضی‌شده یا chainی که آفلاین کامل نمی‌شود. این پرسش policy مربوط به operator است، نه پرسش integrity document و همین تفاوت پشت بیشتر caseهای یادداشت دلیل رد کردن signatureهای PAdES از نظر cryptographic سالم توسط validatorها قرار دارد

macOS واقعاً کجا revocation را check می‌کند؟

داخل trust evaluation و به همین دلیل TPdfCmsVerifyResult.RevocationStatus بعد از TrustStatus می‌آید و verdict مستقل خودش را ندارد. SecPolicyCreateRevocation یک policy می‌سازد و آن policy به SecPolicyCreateBasicX509 در arrayای که به CMSDecoderCopySignerStatus داده می‌شود join می‌شود؛ کار OCSP یا CRL در جایی اتفاق می‌افتد که chain ساخته می‌شود. پاسخ جداگانه‌ای برنمی‌گردد و report کردن یکی یعنی invent کردن آن. خود array نیز یک rule مالکیت کوچک دارد که ارزش نام بردن دارد: CFArrayCreate هر دو policy را retain می‌کند، پس referenceهای local بلافاصله release می‌شوند؛ case تک‌policy اصلاً array را skip می‌کند و policy را مستقیم می‌دهد، شکلی که API آن را هم می‌پذیرد

عملیات offline یک flag صریح است، نه اتفاقی ناشی از connectivity. وقتی TPdfCmsVerifyOptions.OnlineRetrieval False باشد، backend مقدار kSecRevocationNetworkAccessDisabled را اضافه می‌کند و evaluation را به responseهای از قبل cache‌شده روی machine محدود می‌کند. callback مربوط به checkpoint نیز pcvstCryptographicSignature، pcvstChainBuild و pcvstRevocationCheck را در همان ترتیبی fire می‌کند که backend Windows گزارش می‌دهد. application code همه این‌ها را از طریق record سطح بالاتر option تنظیم می‌کند

var
  Options: TPadesTrustValidationOptions;
  Report: TPadesValidationResult;
  Stream: TFileStream;
begin
  Options:= TPadesTrustValidationOptions.Default;
  Options.CheckRevocation:= True;
  Options.NetworkPolicy:= ptnpOffline;   // فقط responseهای cache‌شده
  Options.CheckTimeStamps:= True;

  Stream:= TFileStream.Create('contract.pdf', fmOpenRead or fmShareDenyWrite);
  try
    Report:= ValidatePadesTrust(Stream, Options);
  finally
    Stream.Free;
  end;

  if Report.SignatureCount= 0 then
    Log('No signature dictionary in this document')
  else if Report.Signatures[0].CmsSignatureStatus <> pcsValid then
    Log('Document integrity failed')
  else if Report.Signatures[0].CertificateTrustStatus <> pcsValid then
    Log('Bytes intact, chain not trusted on this Mac');
end;

Get در برابر copy: releaseای که جای دیگری fail می‌شود

SecTrustGetCertificateAtIndex semantic از نوع get دارد و referenceای که برمی‌گرداند هرگز نباید release شود، در حالی که CMSDecoderCopySignerCert و SecCertificateCopyData که چند line آن‌طرف‌تر در همان routine هستند، semantic از نوع copy دارند و باید release شوند. Core Foundation کل rule را در یک verb از function name encode کرده اما type system هیچ‌کدام را enforce نمی‌کند. reference قرض‌گرفته‌شده را release کنید؛ در call site هیچ اتفاقی نمی‌افتد و object مربوط به trust فقط unsound می‌شود، سپس crash بعداً در جایی رخ می‌دهد که هیچ connection ظاهری با certificate chain ندارد

ChainCount:= _SecTrustGetCertificateCount(Trust);
SetLength(Result.ChainCertificates, ChainCount);
for I:= 0 to ChainCount- 1 do
begin
  // semantic از نوع Get: این reference borrowed است و اینجا release نمی‌شود
  Cert:= _SecTrustGetCertificateAtIndex(Trust, I);
  if Cert= nil then
    Continue;
  // semantic از نوع Copy: این یکی owned است و باید برگردانده شود
  CertData:= _SecCertificateCopyData(Cert);
  if CertData= nil then
    Continue;
  try
    Result.ChainCertificates[I]:= CFDataToBytes(CertData);
  finally
    _CFRelease(CertData);
  end;
end;

وقتی هیچ backendی پاسخ نمی‌دهد، verifier چه چیزی را تضمین می‌کند؟

اینکه پاسخ unsupported است، هرگز یک pass خاموش نیست. وقتی ConfigurePadesCmsVerifier چیزی install نکرده باشد و default مربوط به platform هم کمکی نکند، TPdfCmsVerifyResult با همه columnها به‌صورت unavailable برمی‌گردد و PAdES validator آن را به pcsUnsupported map می‌کند؛ بنابراین build بدون crypto backend، به‌جای ادعا کردن چیزی درباره signature، صادقانه گزارش می‌دهد. binding مربوط به macOS نیز عمداً همین جهت محافظه‌کارانه را دارد: Security.framework و CoreFoundation از طریق dlopen و dlsym resolve می‌شوند، پس framework غایب یا symbol name اشتباهی که این binding گرفته است، با برگرداندن False از KeychainVerificationAvailable و نام بردن offender در KeychainMissingSymbols ظاهر می‌شود؛ نه به‌شکل link failure و نه به‌شکل verdict اشتباه. این همان posture از نوع fail-closed است که component هنگام جست‌وجوی native library نیز دارد و در مطلب load کردن native library مربوط به PDFium روی هر target توضیح داده شده است

verification signature بخشی از PDF stack است که در آن wrong بودن خاموش از unavailable بودن پرسر‌وصدا بدتر است و macOS APIای می‌دهد که رسیدن به هر دو outcome را آسان می‌کند. byte rangeها را concatenate کنید و هزینه copy را بپذیرید، verdict مربوط به signature و chain را در columnهای جدا نگه دارید، verbهای get و copy را رعایت کنید و بگذارید backend غایب همین را گزارش کند. اگر workflow سند Delphi یا Free Pascal را به Mac منتقل می‌کنید و در هر دو طرف به signing و validation از نوع PAdES نیاز دارید، PDFium Delphi Component backend مربوط به Keychain را کنار backend Windows و پشت یک interface واحد ارائه می‌کند