Техническая статья

Подписание PAdES с идентичностью из macOS Keychain в Delphi

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 возвращает адрес структуры, что и есть ровно тот указатель, которого ждёт функция создания словаря. Разыменуете её — передадите первое машинное слово структуры, как будто это указатель

Ни одна из ошибок не даёт ошибки компиляции, и ни одна не даёт ясной ошибки рантайма. Вы получаете мусорный указатель, который падает где-то ниже по течению. Способ сделать различие неперепутуемым — перестать полагаться на память: две хелпер-функции, одна связывает и разыменовывает, другая связывает и не разыменовывает, так что место вызова объявляет, какой вид символа оно просит, а хелпер обеспечивает остальное

Диаграмма бэкенда macOS Keychain в PDFium VCL, резолвящего символы Security и CoreFoundation через dlsym: kSecClass — экспортированная переменная, которую BindConstant разыменовывает один раз, получая значение CFStringRef, а kCFTypeDictionaryKeyCallBacks — экспортированная структура, которую BindStruct передаёт по адресу, и перепутывание двух правил даёт мусорные указатели ниже по течению
Один вызов dlsym возвращает две категорически разные вещи: адрес переменной, хранящей CFTypeRef, и адрес структуры колбэков. Два хелпера принимают решение разыменовывать или нет на месте биндинга, а не в памяти
// Экспортированная переменная: 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

Цепочка решений, показывающая, почему подписанию RSA-PSS в Keychain-бэкенде PDFium VCL нужны два независимых фолбэка: dlsym возвращает nil для константы подписи дайджеста на macOS старше 10.13, SecKeyIsAlgorithmSupported может отклонить аппаратный ключ, и оба гейта воронкой сходятся в один даунгрейд PKCS#1 v1.5, у которого обязан измениться и CMS-идентификатор алгоритма
PSS может оказаться недоступен дважды: раз на версию macOS и раз на ключ, и только версионный гейт — системный вопрос. Оба гейта воронкой сходятся в один даунгрейд v1.5, и CMS-идентификатор следует за ним

Кодирование подписи ECDSA и разворот, о котором стоит помнить

Путь эллиптических кривых на macOS вообще не требует конверсии, и это противоположность тому, что требует биндинг PKCS#11. Алгоритм подписи дайджеста Security framework для ECDSA возвращает подпись уже в форме X9.62 DER — ровно то, чего хочет CMS. Токен PKCS#11 возвращает вместо этого сырую пару фиксированной ширины P1363, которую надо перекодировать, прежде чем класть в структуру подписи

Так что два бэкенда, реализующих один интерфейс, требуют противоположного обращения с одним и тем же алгоритмом, и ни один не неправилен. Это ровно тот род различий, который абстракция обязана поглощать, а не выставлять: слой PAdES просит провайдера подписать, и конвенции кодирования остаются внутри провайдера. Просочатся вверх — каждый вызывающий окажется с пер-бэкендной условной конструкцией. Та же форма видна в истории удалённого подписания из удалённых сессий подписания PAdES против HSM

Сравнение кодирования подписи ECDSA между двумя бэкендами подписанта PAdES в PDFium VCL: Security framework macOS Keychain возвращает X9.62 DER, который CMS принимает с нулевой конверсией, а токен PKCS#11 возвращает сырую пару фиксированной ширины P1363, которую надо перекодировать, поэтому ResolvePadesSigner держит конвенции кодирования внутри провайдера
Один и тот же интерфейс ECDSA требует противоположного обращения по бэкендам: Security отдаёт готовый DER, а токен PKCS#11 — сырой P1363, поэтому конверсия живёт внутри провайдера, и вызывающие никогда не видят пер-бэкендной условности
// Интерфейс провайдера одинаков на каждой платформе, поэтому выбор —
// решение на старте, а не на каждый вызов
{$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, так что если имя символа и надо поправить, это изменение одной строки в вашем дереве, а не тикет в поддержку