PDFium VCL подписывает PAdES-документы приватным ключом из macOS Keychain через бэкенд, который резолвит каждый символ Security и CoreFoundation в рантайме через dlopen и dlsym. Ничто не связано на линковке, а значит, опечатка в имени символа проявится как KeychainAvailable, возвращающий False, и KeychainMissingSymbols, называющий виновника, а не как ошибка линковщика или краш
Этот выбор навязан неудобным ограничением, и способ его обработки обобщается. Юнит писался на машине без macOS SDK, поэтому каждое имя символа фреймворка и каждая константа взяты из документации, и ни одно нельзя было сверить с заголовком. Неправильный ответ на такую ситуацию — писать код внимательно и надеяться. Правильный — устроить так, чтобы неизбежные ошибки объявляли о себе в самой отыскиваемой форме
Почему динамический биндинг — правильное решение даже на целевой платформе
Потому что он превращает класс отказа, останавливающего программу, в класс отказа, докладывающего о себе. Статически связанная ссылка на фреймворк, будучи неправильной, падает на линковке на целевой платформе и нигде больше не линкуется. Динамически связанная, будучи неправильной, даёт недоступный бэкенд и список неразрешённых имён, и первый запуск на Mac превращает вопрос «почему это недоступно» в одну строку, называющую опечатку
Есть вторая выгода, окупающаяся ежедневно, а не единожды. Поскольку юнит не линкует фреймворки, он компилируется на каждой платформе, и обычная Windows-сборка продолжает проверять его синтаксис, типы и uses. Юнит, который компилируется только на платформе, которой нет ни у кого в команде, — юнит, на который не смотрит ни один компилятор, и он тихо гниёт с каждым рефакторингом общего типа
uses
FPdfCrypto, FPdfCryptoMac;
var
Options: TPadesSignerOptions;
begin
if not KeychainAvailable then
raise Exception.Create('Keychain backend unavailable, unresolved: ' +
KeychainMissingSymbols);
ConfigureKeychainSignerProvider; // ставим как бэкенд подписанта PAdES
ConfigureKeychainCmsVerifier; // и как бэкенд верификации
Writeln('signer backend : ', PadesCryptoBackendName);
Writeln('verify backend : ', PadesCmsVerificationBackendName);
Options := TPadesSignerOptions.Default;
Options.CertificateThumbprint := 'B1 3F 9C ...'; // SHA-1, любой регистр
Options.PaddingScheme := psRsaPss;
end;
Два вида экспортированных символов, два способа их читать
Это самая сбивающая с толку деталь во всём биндинге, и перепутать её — значит чисто скомпилироваться и упасть в рантайме. CoreFoundation и Security экспортируют через один и тот же вызов dlsym две категорически разные вещи, и код обязан знать, какая где
Именованные константы, вроде ключей классов элементов keychain и булевых синглтонов CoreFoundation, — экспортированные переменные, чьё содержимое и есть нужный вам CFStringRef или CFBooleanRef. dlsym возвращает адрес этой переменной, поэтому разыменовать надо один раз, чтобы получить значение. Структуры таблиц колбэков, вроде колбэков ключей и значений словаря, — экспортированные структуры, и dlsym возвращает адрес структуры, что и есть ровно тот указатель, которого ждёт функция создания словаря. Разыменуете её — передадите первое машинное слово структуры, как будто это указатель
Ни одна из ошибок не даёт ошибки компиляции, и ни одна не даёт ясной ошибки рантайма. Вы получаете мусорный указатель, который падает где-то ниже по течению. Способ сделать различие неперепутуемым — перестать полагаться на память: две хелпер-функции, одна связывает и разыменовывает, другая связывает и не разыменовывает, так что место вызова объявляет, какой вид символа оно просит, а хелпер обеспечивает остальное
// Экспортированная переменная: dlsym даёт адрес переменной, хранящей
// CFTypeRef, поэтому разыменовать один раз
FSecClassKey := BindConstant(SecurityLib, 'kSecClass');
// Экспортированная структура: dlsym даёт адрес САМОЙ структуры, что и
// нужно API. Не разыменовывать
FKeyCallbacks := BindStruct(CoreFoundationLib,
'kCFTypeDictionaryKeyCallBacks');
Почему подписи RSA-PSS нужны два отдельных фолбэка?
Потому что алгоритм может отсутствовать двумя независимыми способами, и лишь один из них — вопрос версии. Константа алгоритма подписи дайджеста PSS появилась в macOS 10.13, поэтому на более старой системе символа просто нет, и биндинг получает nil. Это проверка версии. Отдельно, на системе, где константа есть, конкретный ключ может всё равно от неё отказаться, и фреймворк отвечает на этот вопрос через SecKeyIsAlgorithmSupported для этого ключа. Аппаратный ключ или ключ с ограничительными атрибутами может отклонить PSS, тогда как программный ключ на той же машине его примет
Оба пути обязаны вести к одному фолбэку: переключиться на PKCS#1 v1.5. И критичная часть в том, что фолбэк обязан поменять и идентификатор алгоритма, записываемый в CMS-структуру, а не только вызов подписания. Выдать идентификатор алгоритма PSS, реально производя подпись v1.5, — значит получить документ, который любой верификатор отвергнет сразу, что строго хуже, чем репорт о неподдерживаемом PSS. Даунгрейд приемлем, несоответствие между тем, что вы объявили, и тем, что сделали, — нет, и это общее правило для кода подписей, а не причуда macOS. Подписные следствия разложены в подписании PDF с PAdES B-B
Кодирование подписи ECDSA и разворот, о котором стоит помнить
Путь эллиптических кривых на macOS вообще не требует конверсии, и это противоположность тому, что требует биндинг PKCS#11. Алгоритм подписи дайджеста Security framework для ECDSA возвращает подпись уже в форме X9.62 DER — ровно то, чего хочет CMS. Токен PKCS#11 возвращает вместо этого сырую пару фиксированной ширины P1363, которую надо перекодировать, прежде чем класть в структуру подписи
Так что два бэкенда, реализующих один интерфейс, требуют противоположного обращения с одним и тем же алгоритмом, и ни один не неправилен. Это ровно тот род различий, который абстракция обязана поглощать, а не выставлять: слой PAdES просит провайдера подписать, и конвенции кодирования остаются внутри провайдера. Просочатся вверх — каждый вызывающий окажется с пер-бэкендной условной конструкцией. Та же форма видна в истории удалённого подписания из удалённых сессий подписания PAdES против HSM
// Интерфейс провайдера одинаков на каждой платформе, поэтому выбор —
// решение на старте, а не на каждый вызов
{$IFDEF DARWIN}
if KeychainAvailable then
ConfigureKeychainSignerProvider;
{$ENDIF}
{$IFDEF MSWINDOWS}
// CNG-провайдер Windows ставится платформенным юнитом
{$ENDIF}
if not PadesCryptoAvailable then
raise Exception.Create('no signing backend on this platform');
// Дальше код подписания платформо-нейтрален
Signer := ResolvePadesSigner(Options);
Правила подсчёта ссылок, сидящие в трёх строках друг от друга
Управление памятью Core Foundation следует конвенциям имён, и ловушка здесь в том, что функции с разными конвенциями стоят рядом в одном коротком блоке. Функция, которая получает сертификат из trust-объекта, возвращает заимствованную ссылку, которую освобождать нельзя. Функции, которые копируют сертификат подписанта или его данные, возвращают владеющие ссылки, которые освобождать надо. Три вызова подряд, два правила владения, и освобождение заимствованной не падает на этой строке. Она портит retain-счётчик и валит что-то постороннее позже
Митигируется чтением глагола в имени каждой функции фреймворка перед написанием очистки, каждый раз, без исключений. Это CoreFoundation-эквивалент проверки, возвращает API копию или представление, и цена ошибки — перемежающийся краш, а не ошибка
Чего этот бэкенд не заявляет
На момент написания он никогда не запускался на macOS, и сказать это прямо полезнее, чем подразумеваемое заверение. Демонстративно истинное уже, но всё ещё ценно: юнит компилируется на Windows в составе ежедневной сборки, каждый символ фреймворка связывается по имени в рантайме с перечислением отказов, а логика выбора алгоритмов, включая оба PSS-фолбэка, — обычный Pascal, который можно ревьюить и о котором можно рассуждать. Первый запуск на Mac либо сработает, либо выдаст список имён на починку
Верификационный двойник, использующий CMS-декодер более высокого уровня вместо ручной сборки CMS-структуры, разобран в верификации PDF-подписей на macOS с SecTrust, и он делит ту же инфраструктуру биндинга и тот же диагностический подход
Переносимая идея здесь — о размещении риска, а не о macOS. Когда вы обязаны писать код против интерфейса, который не можете проверить, выбирайте конструкцию, где ошибки дешевле всего локализовать. Динамический биндинг с явным списком неразрешённых имён превращает двадцать непроверяемых допущений в одну диагностическую строку. Оба бэкенда поставляются исходниками с Delphi-компонентом PDFium, так что если имя символа и надо поправить, это изменение одной строки в вашем дереве, а не тикет в поддержку