Technischer Artikel

PDF-Signaturen auf macOS mit SecTrust prüfen

Die PDFium Delphi Component prüft PDF-Signaturen unter macOS über TPdfKeychainCmsVerifier, ein CMS-Verifizierungsbackend auf Basis von Apple CMSDecoder und SecTrust und nicht über selbst geparstes CMS. ConfigureKeychainCmsVerifier installiert es, und ein einziger Aufruf von CMSDecoderCopySignerStatus liefert das Signatururteil, ein SecTrust-Handle und einen Zertifikat-Ergebniscode zurück. Genau dieses Paar aus Spalten führte TPdfCmsVerifyResult unter Windows bereits

Der Auslöser für die Arbeit war alltäglich. Ein Lazarus-Build eines Dokumentarchivs läuft auf einem Mac, öffnet einen signierten Vertrag, und jede Signatur kommt als pcsUnsupported zurück. Mit der Datei ist nichts falsch. Außerhalb von Windows gab es schlicht kein Backend für die Signaturprüfung, und der PAdES-Validator verweigerte mangels eines Backends jede Vermutung. Version 3.111.0 von PDFiumPas öffnete die Schnittstelle mit IPdfCmsVerifier und ConfigurePadesCmsVerifier; Version 3.113.0 füllte sie auf macOS. Das Interessante an diesem Port ist nicht die Verkabelung, sondern die drei Stellen, an denen die Apple-API eine andere Form als die Windows-API hat

Warum umfasst eine PDF-Signatur zwei Bytebereiche?

Weil eine Signatur die Bytes, die sie selbst enthalten, nicht signieren kann. ISO 32000-1 §12.8.1 legt den CMS-SignedData-Blob in den /Contents-String des Signaturwörterbuchs und beschreibt den signierten Bereich mit /ByteRange, einer Folge von Offset-Längen-Paaren, die alles auf beiden Seiten dieser Lücke abdeckt. Auf jeder Plattform sind es zwei Segmente und eine Lücke dazwischen

Die Plattformen unterscheiden sich darin, wie diese Segmente zur Kryptoschicht gelangen, und dieser Unterschied kostet Speicher. Unter Windows akzeptiert CryptVerifyDetachedMessageSignature ein Array aus Zeigern und Längen, daher werden beide Spannen so übergeben, wie sie im Puffer liegen, und nichts wird dupliziert. Apple CMSDecoderSetDetachedContent akzeptiert ein einzelnes CFData und hat keine Mehrsegmentform, daher verkettet das macOS-Backend die beiden Bereiche vor dem Dekodieren in einem zusammenhängenden Puffer. Das ist eine vollständige zweite Kopie der signierten Bytes. Bei einem gescannten Archiv mit 400 MB ist das ein echter Speicherpeak, der mit dem Dokument und nicht mit der Signatur skaliert, und es gibt keine alternative API. Legen Sie den Batch-Worker entsprechend aus, statt dies auf einem Kundenrechner zu entdecken

Ein Aufruf füllt zwei Spalten von TPdfCmsVerifyResult

CMSDecoderCopySignerStatus ist für einen Security.framework-Einstiegspunkt ungewöhnlich großzügig: Ein Aufruf liefert den Signerstatus, ein SecTrustRef für die aufgebaute Kette und einen OSStatus für die Zertifikatsbewertung. Diese Werte landen direkt in dem Record, den der PAdES-Validator bereits konsumiert. Der Signerstatus wird zu SignatureStatus, das Zertifikatsergebnis zu TrustStatus, und die Rohwerte bleiben in SignatureError und TrustError erhalten, sodass ein Supportticket eine Zahl statt eines Adjektivs zitieren kann. Aufrufer berühren IPdfCmsVerifier nie selbst – ValidatePadesCompliance und ValidatePadesTrust leiten jede Prüfung über das installierte Backend. Der Code, der TPadesSignatureValidation liest, ist daher auf beiden Plattformen byteweise derselbe, wie im Leitfaden zum Untersuchen von PDF-Signaturwörterbüchern und PAdES-Stufen in Delphi beschrieben

uses
  FPdfCrypto, FPdfCryptoMac, FPdfPades;

procedure InstallMacVerifier;
begin
  // Signieren und Verifizieren lösen unterschiedliche Framework-Symbole auf,
  // daher kann eines vorhanden sein, während das andere fehlt
  if not KeychainVerificationAvailable then
    raise Exception.CreateFmt('Security.framework symbols missing: %s',
      [KeychainMissingSymbols]);

  ConfigureKeychainCmsVerifier;

  // PadesCmsVerificationBackendName liefert jetzt 'macOS Security.framework'
  if not PadesCmsVerificationAvailable then
    raise Exception.Create('No CMS verification backend is installed');
end;

Warum meldet kCMSSignerInvalidCert eine gültige Signatur?

Weil Apple diesem Wert eine engere Bedeutung gibt, als sein Name vermuten lässt: Die Signatur selbst wurde verifiziert, nur die Zertifikatskette konnte nicht aufgebaut werden. TPdfKeychainCmsVerifier bildet kCMSSignerInvalidCert deshalb in der Spalte SignatureStatus auf pcvsValid ab und lässt das Zertifikatsproblem über TrustStatus sichtbar werden, wo ein Kettenproblem hingehört. Würde man es in das Signatururteil einfalten, würde die Komponente einem Operator sagen, ein unverändertes Dokument sei geändert worden. Das ist der schlimmste Fehlalarm, den ein Signaturvalidator auslösen kann

function MapSignerStatus(Status: LongWord): TPdfCmsVerifyStatus;
begin
  case Status of
  kCMSSignerValid:
    Result:= pcvsValid;
  // Die Signatur ist gültig, nur die Kette nicht; das meldet der
  // Trust-Status selbstständig
  kCMSSignerInvalidCert:
    Result:= pcvsValid;
  kCMSSignerInvalidSignature, kCMSSignerUnsigned:
    Result:= pcvsInvalid;
  else
    Result:= pcvsIndeterminate;
  end;
end;

Liest man beide Status als geordnetes Paar, ergibt sich die Berichtslogik von selbst. SignatureStatus = pcvsValid zusammen mit TrustStatus = pcvsInvalid beschreibt ein Dokument, dessen Bytes intakt sind, dessen Aussteller dieser konkrete Mac aber nicht vertraut: ein fehlender Anker im Schlüsselbund, ein abgelaufenes Zwischenzertifikat oder eine offline nicht vervollständigbare Kette. Das ist eine Operatorrichtlinie und keine Frage der Dokumentintegrität – genau diese Unterscheidung steckt hinter den meisten Fällen in der Notiz zu der Ablehnung kryptografisch intakter PAdES-Signaturen durch Validatoren

Wo prüft macOS tatsächlich die Sperrung?

Innerhalb der Vertrauensbewertung, weshalb TPdfCmsVerifyResult.RevocationStatus auf TrustStatus folgt und kein eigenes Urteil trägt. SecPolicyCreateRevocation erzeugt eine Policy, diese Policy wird im an CMSDecoderCopySignerStatus übergebenen Array mit SecPolicyCreateBasicX509 verbunden, und die OCSP- oder CRL-Arbeit findet beim Aufbau der Kette statt. Es kommt keine separate Antwort zurück; eine solche zu melden, würde bedeuten, sie zu erfinden. Das Array trägt außerdem eine kleine Ownership-Regel, die man benennen sollte: CFArrayCreate retained beide Policies, daher werden die beiden lokalen Referenzen sofort danach freigegeben. Der Ein-Policy-Fall überspringt das Array und übergibt die Policy direkt, eine Form, die die API ebenfalls akzeptiert

Offlinebetrieb ist ein ausdrückliches Flag und kein Zufall der Verbindung. Wenn TPdfCmsVerifyOptions.OnlineRetrieval False ist, fügt das Backend kSecRevocationNetworkAccessDisabled hinzu und begrenzt die Prüfung auf Antworten, die bereits auf dem Rechner gecacht sind. Der Checkpoint-Callback feuert weiterhin pcvstCryptographicSignature, pcvstChainBuild und pcvstRevocationCheck in derselben Reihenfolge wie das Windows-Backend. Anwendungscode setzt all dies über den übergeordneten Options-Record

var
  Options: TPadesTrustValidationOptions;
  Report: TPadesValidationResult;
  Stream: TFileStream;
begin
  Options:= TPadesTrustValidationOptions.Default;
  Options.CheckRevocation:= True;
  Options.NetworkPolicy:= ptnpOffline;   // nur gecachte Antworten
  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 versus Copy: die Freigabe, die anderswo scheitert

SecTrustGetCertificateAtIndex hat Get-Semantik, und die zurückgegebene Referenz darf nie freigegeben werden, während CMSDecoderCopySignerCert und SecCertificateCopyData wenige Zeilen weiter in derselben Routine Copy-Semantik haben und freigegeben werden müssen. Core Foundation kodiert die gesamte Regel in einem Verb des Funktionsnamens, das Typsystem setzt sie nicht durch. Gibt man die geliehene Referenz frei, geht am Aufrufort nichts schief: Das Trust-Objekt wird einfach inkonsistent, und der Absturz kommt später an einer Stelle ohne sichtbaren Zusammenhang mit Zertifikatsketten

ChainCount:= _SecTrustGetCertificateCount(Trust);
SetLength(Result.ChainCertificates, ChainCount);
for I:= 0 to ChainCount- 1 do
begin
  // Get-Semantik: Diese Referenz ist geliehen und wird hier nicht freigegeben
  Cert:= _SecTrustGetCertificateAtIndex(Trust, I);
  if Cert= nil then
    Continue;
  // Copy-Semantik: Diese Referenz gehört uns und muss zurückgegeben werden
  CertData:= _SecCertificateCopyData(Cert);
  if CertData= nil then
    Continue;
  try
    Result.ChainCertificates[I]:= CFDataToBytes(CertData);
  finally
    _CFRelease(CertData);
  end;
end;

Was garantiert der Verifier, wenn kein Backend antwortet?

Die Antwort ist unsupported und niemals ein stiller Erfolg. Wenn ConfigurePadesCmsVerifier kein Backend installiert hat und der Plattformdefault nicht helfen kann, kommt TPdfCmsVerifyResult mit allen Spalten auf unavailable zurück, und der PAdES-Validator bildet das auf pcsUnsupported ab. Ein Build ohne Kryptobackend meldet somit ehrlich, statt etwas über die Signatur zu behaupten. Die macOS-Bindung ist in derselben Richtung bewusst konservativ: Security.framework und CoreFoundation werden über dlopen und dlsym erreicht. Ein fehlendes Framework oder ein von dieser Bindung falsch geschriebener Symbolname zeigt sich daher als False-Rückgabe von KeychainVerificationAvailable mit dem Übeltäter in KeychainMissingSymbols, nicht als Linkfehler und nicht als falsches Urteil. Das ist dieselbe Fail-closed-Haltung, die die Komponente beim Suchen nach der nativen Bibliothek einnimmt, beschrieben im Beitrag zum Laden der nativen PDFium-Bibliothek auf jedem Ziel

Signaturprüfung ist der Teil eines PDF-Stacks, bei dem ein stiller Fehler schlimmer ist als ein laut gemeldetes Nichtverfügbarsein, und macOS bietet eine API, bei der beides leicht erreichbar ist. Verketten Sie die Bytebereiche und akzeptieren Sie die Kopie, halten Sie Signatururteil und Kettenurteil in getrennten Spalten, respektieren Sie die Verben Get und Copy und lassen Sie ein fehlendes Backend genau das sagen. Wenn Sie einen Delphi- oder Free-Pascal-Dokumentworkflow auf den Mac verlagern und PAdES-Signieren und -Validieren auf beiden Seiten benötigen, liefert die PDFium Delphi Component das Keychain-Backend neben dem Windows-Backend hinter einer einheitlichen Schnittstelle