Технічна стаття

Підписування 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 два категоріально різні речі, і код мусить знати, яке з чим

Іменовані константи, як-от ключі класів елементів кейчейна і булеві синглтони 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 потрібні два окремі fallback'и?

Бо алгоритм може бути відсутнім двома незалежними способами, і лише один із них — питання версії. Константа алгоритму підписування дайджесту PSS з'явилася в macOS 10.13, тож на старішій системі символу просто немає, і прив'язка отримує nil. Це перевірка версії. Окремо, на системі, де константа існує, конкретний ключ може все одно її відмовити, і фреймворк відповідає на те питання через SecKeyIsAlgorithmSupported для того ключа. Апаратний ключ чи ключ з обмежувальними атрибутами може відхилити PSS, тоді як програмний ключ на тій самій машині його приймає

Обидва шляхи мусять вести до того самого fallback: перейти на PKCS#1 v1.5. І критична частина в тому, що fallback мусить змінити також ідентифікатор алгоритму, записаний у структуру CMS, а не лише виклик підписування. Випустити ідентифікатор алгоритму PSS, реально продукуючи підпис v1.5, — значить дати документ, який кожен верифікатор відкидає одразу, що строго гірше, ніж повідомити, що PSS не підтримується. Даунгрейд прийнятний, невідповідність між тим, що ви декларуєте, і тим, що ви зробили, — ні, і це загальне правило для сигнатурного коду, а не примха macOS. Сигнатурно-рівневі наслідки розкладені в підписуванні PDF з PAdES B-B

Ланцюг рішень, що показує, чому підписування RSA-PSS у Keychain-бекенді PDFium VCL потребує двох незалежних fallback'ів: 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}
  // Провайдер Windows CNG встановлюється юнітом платформи
{$ENDIF}

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

// Звідси код підписування платформо-нейтральний
Signer := ResolvePadesSigner(Options);

Правила підрахунку посилань, що сидять у трьох рядках

Керування пам'яттю Core Foundation слідує конвенціям іменування, і пастка тут у тому, що функції з різними конвенціями з'являються поруч у тому самому короткому блоці. Функція, яка отримує сертифікат з об'єкта довіри, повертає позичене посилання, яке не можна відпускати. Функції, які копіюють сертифікат підписувача чи копіюють його дані, повертають володільні посилання, які треба відпускати. Три виклики поспіль, два правила власності, і відпускання позиченого не падає на тому рядку. Воно спотворює retain-лічильник і валить щось нерелевантне пізніше

Пом'якшення — читати дієслово в кожному імені функції фреймворку перед написанням очищення, щоразу, без винятків. Це CoreFoundation-еквівалент перевірки, чи API повертає копію, чи подання, а ціна помилки — переривчасте падіння, а не помилка

Чого цей бекенд не заявляє

Він ніколи не бігав на macOS на момент написання, і сказати це прямо корисніше, ніж натяк на запевнення. Демонстративно істинне — вужче й досі цінне: юніт компілюється на Windows у складі щоденної збірки, кожен символ фреймворку підключається на ім'я під час виконання з переліченими відмовами, а логіка вибору алгоритму, включно з обома fallback'ами PSS, — звичайний Паскаль, який можна переглянути й обміркувати. Перший запуск на Mac або запрацює, або дасть список імен до виправлення

Верифікаційний двійник, який використовує вищий декодер CMS замість збирання CMS-структури руками, покритий у верифікації PDF-підписів на macOS з SecTrust, і він ділить ту саму інфраструктуру прив'язки і той самий діагностичний підхід

Переносна ідея тут — про розміщення ризику, а не про macOS. Коли ви мусите писати код проти інтерфейсу, який не можете верифікувати, обирайте конструкцію, де помилки найдешевше локалізувати. Динамічне підключення з явним списком нерозв'язаних імен перетворює двадцять неперевірених припущень на одну діагностичну лінію. Обидва бекенди постачаються сирцями з PDFium Delphi component, тож якщо якусь назву символу таки треба виправити, це однорядкова зміна у власному дереві, а не тикет у підтримку