技術記事

macOS SecTrustでPDF signatureをverifyするDelphi

PDFium Delphi Componentは、AppleのCMSDecoderとSecTrustを使ってCMS verificationを行うTPdfKeychainCmsVerifierを通じ、macOSでPDF signatureをverifyします。手作業でparseしたCMSには依存しません。ConfigureKeychainCmsVerifierがこれをinstallし、1回のCMSDecoderCopySignerStatus callがsignature verdict、構築したchainのSecTrust handle、certificate result codeを返します。これはWindowsでTPdfCmsVerifyResultがすでに持っていた2つのcolumnとまったく同じ組み合わせです

作業を迫った状況は地味でよくあります。document archiveのLazarus buildがMac上で動き、signed contractを開きますが、すべてのsignatureがpcsUnsupportedになります。fileに問題はありません。Windows以外にsignature verification backendがなく、PAdES validatorはbackend不在で推測を拒否しただけです。PDFiumPas v3.111.0はIPdfCmsVerifierConfigurePadesCmsVerifierでseamを開き、v3.113.0でmacOSを埋めました。このportで興味深いのはplumbingではなく、Apple APIがWindows APIと同じ形をしていない3つの場所です

PDF signatureが2つのbyte rangeをcoverする理由

signature自身を含むbyteをsignatureでcoverできないためです。ISO 32000-1 §12.8.1では、CMS SignedData blobをsignature dictionaryの/Contents stringに置き、signed extentを/ByteRangeで表します。これはholeの両側にあるすべてをcoverするoffsetとlengthのpairです。すべてのplatformで2 segment、中央に1つのgapです

そのsegmentをcrypto layerへ届ける方法がplatform間で異なり、memory costも変わります。WindowsのCryptVerifyDetachedMessageSignatureはpointerとlengthのarrayを受け取るため、2つのspanをbuffer上の位置のまま渡せ、duplicateはありません。AppleのCMSDecoderSetDetachedContentは1つのCFDataしか受け取らず、multi-segment formがありません。そのためmacOS backendはdecode前に2つのrangeをcontiguous bufferへconcatenateします。signed byteのfull second copyです。400 MBのscan archiveでは本当のmemory peakになり、signatureではなくdocumentに比例してscaleします。別のAPIへ逃げる方法はありません。customer machineで発見するのではなく、batch workerをそれに合わせてsizeしてください

1回のcallがTPdfCmsVerifyResultの2つのcolumnを埋める

CMSDecoderCopySignerStatusはSecurity.framework entry pointとしては珍しくgenerousです。1回のcallでsigner status、構築したchainのSecTrustRef、certificate evaluationのOSStatusを返します。それらはPAdES validatorがすでにconsumeするrecordへ直接入ります。signer statusはSignatureStatus、certificate resultはTrustStatusとなり、raw valueはSignatureErrorTrustErrorへ保持されます。support ticketで形容詞ではなくnumberを引用できます。callerがIPdfCmsVerifierを直接触ることはありません。ValidatePadesComplianceValidatePadesTrustが、installedされたbackendを通じてすべてのverificationをrouteします。そのためTPadesSignatureValidationを読むcodeは、DelphiでPDF signature dictionaryとPAdES levelをinspectする方法にある通り、両platformでbyte-for-byte同じです

uses
  FPdfCrypto, FPdfCryptoMac, FPdfPades;

procedure InstallMacVerifier;
begin
  // signingとverificationは異なるframework symbolを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がvalid signatureを報告する理由

Appleがそのvalueに、nameから受ける印象より狭い意味を割り当てているためです。signature自体はverifyでき、certificate chainだけをestablishできなかったという意味です。したがってTPdfKeychainCmsVerifierkCMSSignerInvalidCertをSignatureStatus columnのpcvsValidへmapし、certificate problemはTrustStatusへ出します。chain problemが属する場所だからです。signature verdictへfoldすると、tamperされていないdocumentが変更されたとcomponentがoperatorへ告げることになります。これはsignature validatorが上げ得る最悪のfalse alarmです

function MapSignerStatus(Status: LongWord): TPdfCmsVerifyStatus;
begin
  case Status of
  kCMSSignerValid:
    Result:= pcvsValid;
  // signatureはverifyでき、chainだけが失敗した。その結果は独自にtrust
  // statusが報告する
  kCMSSignerInvalidCert:
    Result:= pcvsValid;
  kCMSSignerInvalidSignature, kCMSSignerUnsigned:
    Result:= pcvsInvalid;
  else
    Result:= pcvsIndeterminate;
  end;
end;

2つのstatusをordered pairとして読めば、reporting logicは自然に書けます。SignatureStatus = pcvsValidTrustStatus = pcvsInvalidの組み合わせは、documentのbyteはintactだが、そのissuerをこのMacがtrustしていないことを示します。Keychainにanchorがない、expired intermediateがある、offlineでchainをcompleteできない、といった状況です。これはoperator policyの質問で、document integrityの質問ではありません。この区別が、cryptographically soundなPAdES signatureをvalidatorがrejectする理由の多くを支えています

macOSがrevocationを実際にcheckする場所

trust evaluationの内部です。そのためTPdfCmsVerifyResult.RevocationStatusは自身のverdictを持たずTrustStatusの後に続きます。SecPolicyCreateRevocationがpolicyを生成し、そのpolicyはSecPolicyCreateBasicX509と、CMSDecoderCopySignerStatusへ渡すarray内でjoinします。OCSPまたはCRLの処理はchainがbuildされる場所で行われます。別のanswerは返らないため、報告すればinventになります。array自身にもownership ruleがあります。CFArrayCreateは両policyをretainするので、2つのlocal referenceは直後にreleaseします。single-policy caseではarrayを完全にskipしてpolicyを直接渡します。APIもこのformを受け入れます

offline operationはconnectivityの偶然ではなく、明示的なflagです。TPdfCmsVerifyOptions.OnlineRetrievalがFalseならbackendはkSecRevocationNetworkAccessDisabledを追加し、machineにすでにcacheされたresponseだけへevaluationを限定します。checkpoint callbackはWindows backendと同じ順序でpcvstCryptographicSignaturepcvstChainBuildpcvstRevocationCheckをなお発火します。application codeはこれらすべてをhigher-level option recordで設定します

var
  Options: TPadesTrustValidationOptions;
  Report: TPadesValidationResult;
  Stream: TFileStream;
begin
  Options:= TPadesTrustValidationOptions.Default;
  Options.CheckRevocation:= True;
  Options.NetworkPolicy:= ptnpOffline;   // cached responseだけ
  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

SecTrustGetCertificateAtIndexはget semanticsで、返すreferenceをreleaseしてはいけません。一方、同じroutineの数行先にあるCMSDecoderCopySignerCertSecCertificateCopyDataはcopy semanticsで、releaseしなければなりません。Core Foundationはfunction nameの1つのverbにrule全体を埋め込みますが、type systemは何もenforceしません。borrowed referenceをreleaseしてもcall siteでは何も壊れません。trust objectがunsoundになり、crashはcertificate chainと目に見える関係のない後の場所で発生します

ChainCount:= _SecTrustGetCertificateCount(Trust);
SetLength(Result.ChainCertificates, ChainCount);
for I:= 0 to ChainCount- 1 do
begin
  // Get semantics:このreferenceはborrowedなのでここではreleaseしない
  Cert:= _SecTrustGetCertificateAtIndex(Trust, I);
  if Cert= nil then
    Continue;
  // Copy semantics:こちらはownedなので戻さなければならない
  CertData:= _SecCertificateCopyData(Cert);
  if CertData= nil then
    Continue;
  try
    Result.ChainCertificates[I]:= CFDataToBytes(CertData);
  finally
    _CFRelease(CertData);
  end;
end;

backendが答えないときverifierが保証するもの

answerはunsupportedであり、quiet passではないことです。ConfigurePadesCmsVerifierが何もinstallせず、platform defaultも助けられない場合、TPdfCmsVerifyResultのすべてのcolumnがunavailableになって戻り、PAdES validatorはそれをpcsUnsupportedへmapします。crypto backendのないbuildがsignatureについて何かを主張するのではなく、正直に報告します。macOS bindingも同じ方向にconservativeです。Security.frameworkとCoreFoundationはdlopendlsymでreachするため、frameworkがないかbindingが誤ったsymbol nameを持つ場合、link failureでもwrong verdictでもなく、KeychainVerificationAvailableがFalseを返し、KeychainMissingSymbolsが犯人をnameします。これはどのtargetでもPDFium native libraryをloadする方法で説明した、native library探索時のfail-closed postureと同じです

PDF stackでsignature verificationは、静かに間違うことが大声で使えないことより悪い領域です。byte rangeをconcatenateするcopyを受け入れ、signature verdictとchain verdictを別columnに保ち、getとcopyのverbを尊重し、backendがなければそう言わせてください。DelphiまたはFree Pascalのdocument workflowをMacへ移し、両側でPAdES signingとvalidationが必要なら、PDFium Delphi ComponentはWindows backendと並べてKeychain backendを1つのinterfaceの背後に提供します