Technical Article

Signing PAdES with a macOS Keychain Identity in Delphi

PDFium VCL signs PAdES documents with a private key held in the macOS Keychain through a backend that resolves every Security and CoreFoundation symbol at runtime with dlopen and dlsym. Nothing is link-time bound, which means a mistyped symbol name surfaces as KeychainAvailable returning False and KeychainMissingSymbols naming the culprit, rather than as a linker error or a crash

That choice was forced by an uncomfortable constraint, and the way it was handled generalises. The unit was written on a machine with no macOS SDK, so every framework symbol name and every constant came from documentation and none of it could be checked against a header. The wrong response to that situation is to write the code carefully and hope. The right one is to arrange for the inevitable mistakes to announce themselves in the most locatable form possible

Why dynamic binding is the right call even on the target platform

Because it converts a class of failure that stops the program into a class of failure that reports itself. A statically linked framework reference that is wrong fails at link time on the target and never links elsewhere. A dynamically bound one that is wrong produces an unavailable backend and a list of unresolved names, and the first run on a Mac turns the question from why is this unavailable into a single line naming a typo

There is a second benefit that pays off daily rather than once. Because the unit links no frameworks, it compiles on every platform, so the ordinary Windows build keeps checking its syntax, its types and its uses clause. A unit that only compiles on a platform nobody in the team has is a unit with no compiler looking at it, and it decays quietly with every refactor of a shared type

uses
  FPdfCrypto, FPdfCryptoMac;

var
  Options: TPadesSignerOptions;
begin
  if not KeychainAvailable then
    raise Exception.Create('Keychain backend unavailable, unresolved: ' +
      KeychainMissingSymbols);

  ConfigureKeychainSignerProvider;   // install as the PAdES signer backend
  ConfigureKeychainCmsVerifier;      // and as the verification backend

  Writeln('signer backend  : ', PadesCryptoBackendName);
  Writeln('verify backend  : ', PadesCmsVerificationBackendName);

  Options := TPadesSignerOptions.Default;
  Options.CertificateThumbprint := 'B1 3F 9C ...';   // SHA-1, any casing
  Options.PaddingScheme := psRsaPss;
end;

Two kinds of exported symbol, two ways to read them

This is the single most confusing detail in the whole binding, and getting it backwards compiles cleanly and fails at runtime. CoreFoundation and Security export two categorically different things through the same dlsym call, and the code has to know which is which

Named constants such as the keychain item class keys and the CoreFoundation boolean singletons are exported variables whose contents are the CFStringRef or CFBooleanRef you want. dlsym returns the address of that variable, so you must dereference once to obtain the value. Callback-table structures such as the dictionary key and value callbacks are exported structures, and dlsym returns the address of the structure, which is precisely the pointer the dictionary creation function expects. Dereference that one and you pass the first machine word of the structure as if it were a pointer

Neither mistake produces a compile error, and neither produces a clear runtime error. You get a garbage pointer that fails somewhere downstream. The way to make the distinction impossible to get wrong is to stop relying on remembering it: two helper functions, one that binds and dereferences and one that binds and does not, so the call site declares which kind of symbol it is asking for and the helper enforces the rest

Diagram of the PDFium VCL macOS Keychain backend resolving Security and CoreFoundation symbols through dlsym: kSecClass is an exported variable that BindConstant dereferences once to obtain the CFStringRef value, whilst kCFTypeDictionaryKeyCallBacks is an exported structure that BindStruct passes by address, and mixing up the two rules yields garbage pointers downstream
One dlsym call returns two categorically different things: the address of a variable holding a CFTypeRef and the address of a callback structure. Two helpers make the dereference-or-not decision at the binding site instead of in memory
// Exported variable: dlsym gives the address of a variable holding the
// CFTypeRef, so dereference once
FSecClassKey := BindConstant(SecurityLib, 'kSecClass');

// Exported structure: dlsym gives the address OF the structure, which is
// what the API wants. Do not dereference
FKeyCallbacks := BindStruct(CoreFoundationLib,
  'kCFTypeDictionaryKeyCallBacks');

Why does an RSA-PSS signature need two separate fallbacks?

Because the algorithm can be missing in two independent ways, and only one of them is a version question. The PSS digest-signing algorithm constant appeared in macOS 10.13, so on an older system the symbol simply is not there and the binding gets nil. That is the version check. Separately, on a system where the constant exists, a specific key may still refuse it, and the framework answers that question through SecKeyIsAlgorithmSupported for that key. A hardware-backed key or a key with restrictive attributes can decline PSS whilst a software key on the same machine accepts it

Both paths must lead to the same fallback: switch to PKCS#1 v1.5. And the critical part is that the fallback has to change the algorithm identifier written into the CMS structure as well, not just the signing call. Emitting a PSS algorithm identifier whilst actually producing a v1.5 signature yields a document every verifier rejects outright, which is strictly worse than reporting that PSS is unsupported. A downgrade is acceptable, a mismatch between what you declare and what you did is not, and that is a general rule for signature code rather than a macOS quirk. The signature-level implications are laid out in signing PDFs with PAdES B-B

Decision chain showing why RSA-PSS signing in the PDFium VCL Keychain backend needs two independent fallbacks: dlsym returns nil for the digest-signing constant on macOS versions before 10.13, SecKeyIsAlgorithmSupported can decline a hardware-backed key, and both gates funnel into the same PKCS#1 v1.5 downgrade whose CMS algorithm identifier must change with it
PSS can be unavailable twice over, once per macOS version and once per key, and only the version gate is a system question. Both gates funnel into the same v1.5 downgrade, and the CMS identifier follows

ECDSA signature encoding, and a reversal worth noting

The elliptic-curve path needs no conversion at all on macOS, and that is the opposite of what a PKCS#11 binding requires. The Security framework digest-signing algorithm for ECDSA returns the signature already in X9.62 DER form, which is exactly what CMS wants. A PKCS#11 token returns the raw fixed-width P1363 pair instead, which has to be re-encoded before it goes into a signature structure

So two backends implementing the same interface need opposite treatment for the same algorithm, and neither one is wrong. This is precisely the kind of difference an abstraction has to absorb rather than expose: the PAdES layer asks a provider to sign, and encoding conventions stay inside the provider. If they leak upwards, every caller ends up carrying a per-backend conditional. The same shape appears in the remote signing story described in remote PAdES signing sessions against an HSM

Comparison of ECDSA signature encoding across two backends of the PDFium VCL PAdES signer: the macOS Keychain Security framework returns X9.62 DER that CMS accepts with zero conversion, whilst a PKCS#11 token returns the raw fixed-width P1363 pair that must be re-encoded, so ResolvePadesSigner keeps encoding conventions inside the provider
The same ECDSA interface needs opposite treatment per backend: Security hands over finished DER whilst a PKCS#11 token hands over raw P1363, so the conversion lives inside the provider and callers never see a per-backend conditional
// The provider interface is the same on every platform, so selection is
// a startup decision rather than a per-call one
{$IFDEF DARWIN}
  if KeychainAvailable then
    ConfigureKeychainSignerProvider;
{$ENDIF}
{$IFDEF MSWINDOWS}
  // Windows CNG provider is installed by the platform unit
{$ENDIF}

if not PadesCryptoAvailable then
  raise Exception.Create('no signing backend on this platform');

// From here the signing code is platform-neutral
Signer := ResolvePadesSigner(Options);

Reference counting rules that sit three lines apart

Core Foundation memory management follows naming conventions, and the trap here is that functions with different conventions appear next to each other in the same short block. A function that gets a certificate from a trust object returns a borrowed reference that must not be released. Functions that copy a signer certificate or copy its data return owned references that must be released. Three calls in sequence, two ownership rules, and releasing the borrowed one does not fail at that line. It corrupts a retain count and takes down something unrelated later

The mitigation is to read the verb in every framework function name before writing the cleanup, every time, without exception. It is the CoreFoundation equivalent of checking whether an API returns a copy or a view, and the cost of getting it wrong is an intermittent crash rather than an error

What this backend does not claim

It has never run on macOS at the time of writing, and saying so plainly is more useful than an implied assurance. What is demonstrably true is narrower and still valuable: the unit compiles on Windows as part of the daily build, every framework symbol is bound by name at runtime with the failures enumerated, and the algorithm selection logic including both PSS fallbacks is ordinary Pascal that can be reviewed and reasoned about. The first run on a Mac will either work or produce a list of names to fix

The verification counterpart, which uses the higher-level CMS decoder rather than assembling the CMS structure by hand, is covered in verifying PDF signatures on macOS with SecTrust, and it shares the same binding infrastructure and the same diagnostic approach

The transferable idea here is about risk placement rather than macOS. When you must write code against an interface you cannot verify, choose the construction where mistakes are cheapest to locate. Dynamic binding with an explicit list of unresolved names turns twenty unverifiable assumptions into one diagnostic line. Both backends ship as source with the PDFium Delphi component, so if a symbol name does need correcting, it is a one-line change in your own tree rather than a support ticket