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

Шифрование PDF по сертификатам в Delphi: RSA-OAEP и ECDH

Зашифрованный по сертификату PDF несёт единственное значение /P в своём словаре шифрования, для кого бы файл ни писался; в файлах, собираемых ниже по статье, это -64. Три получателя с тремя разными наборами разрешений делят то же одно число. Код, отвечающий на «может ли этот пользователь печатать?» по /P, читает заглушку, потому что под обработчиком открытых ключей настоящее слово разрешений едет внутри зашифрованного конверта каждого получателя и появляется лишь после того, как приватный ключ того получателя его откроет

Этот дизайн — причина, по которой шифрование по сертификатам стоит возни. Квартальный аудиторский пакет уходит трём внешним ревьюерам; юристы хотят, чтобы все его прочли, печатать может только один, и никто не хочет пароля в почтовой ветке рядом с вложением. Пароль такого не выразит. Сертификаты могут: каждый получатель отпирает файл ключом, который уже держит, и каждый конверт несёт собственное слово разрешений

Чем шифрование PDF по сертификатам отличается от пароля?

Зашифрованный по открытому ключу PDF выводит ключ файла из случайной затравки плюс точные байты каждого конверта получателя, и ничто из набранного человеком в расчёт не входит. Обработчик специфицирован в ISO 32000-1 §7.6.4 (§7.6.5 в ISO 32000-2), а конверты — структуры CMS EnvelopedData из RFC 5652. HotPDF пишет /Filter /Adobe.PubSec с /SubFilter /adbe.pkcs7.s5. Для AES-256 это значит /V 5, запись /DefaultCryptFilter под /CF с /CFM /AESV3 и массив /Recipients, положенный внутрь того crypt filter вместо словаря верхнего уровня

Каждый конверт шифрует 24 байта: 20-байтовую затравку, за которой следует 32-битное слово разрешений этого получателя. При загрузке ридер распаковывает один конверт, восстанавливает затравку и хеширует её вместе с каждым конвертом в порядке /Recipients (SHA-256 для AES-256, SHA-1 для старых шифров), собирая ключ файла заново. Ревьюер B никогда не узнает, что может ревьюер A, но ридер B всё равно хеширует байты конверта A, поэтому весь массив обязан выжить байт в байт. Если вы ещё выбираете между этой моделью и обычными паролями, руководство по шифрованию AES-256 паролем и флагам разрешений покрывает другую сторону того размена

Схема шифрования по открытому ключу в HotPDF: EnablePubKeyEncryption фиксирует 20-байтовую затравку, каждый CMS-конверт EnvelopedData шифрует эти 20 байт плюс одно 32-битное слово разрешений внутри /Filter /Adobe.PubSec с /SubFilter /adbe.pkcs7.s5 и /CFM /AESV3, а ридер распаковывает один конверт, восстанавливает затравку и хеширует её с каждой записью /Recipients в порядке массива, собирая ключ файла заново
Значение /P в словаре шифрования — лишь заполнитель, потому что настоящие разрешения едут внутри каждого конверта, и ничто ниже по цепочке не вправе переставлять или перекодировать массив, по которому идёт хеш

Запись получателей RSA через EnablePubKeyEncryption

Для сертификатов RSA последовательность короткая: EnablePubKeyEncryption с aes256, затем по одному вызову AddPubKeyRecipientCertificate на каждый DER-закодированный сертификат — всё до BeginDoc. Хелпер строит конверт RSAES-OAEP в процессе. Его два последних параметра выбирают дайджест OAEP и дайджест MGF1 из THPDFRSAOAEPHash (rohSHA256, rohSHA384 или rohSHA512), оба по умолчанию rohSHA256, а содержимое конверта шифруется AES-256-CBC

uses
  System.SysUtils, System.IOUtils, HPDFDoc, HPDFCrypt, HPDFRSA;

procedure WriteAuditPack(const OutFile: string);
var
  Pdf: THotPDF;
  Seed: AnsiString;
begin
  SetLength(Seed, 20);                      // ровно 20 байтов, даже для AES-256
  AESGenerateRandomBytes(@Seed[1], Length(Seed));
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := OutFile;
    Pdf.EnablePubKeyEncryption(Seed, aes256, True);   // тип ключа по умолчанию aes128
    // Ревьюер A может печатать; ревьюер B — только читать и извлекать
    Pdf.AddPubKeyRecipientCertificate(TFile.ReadAllBytes('reviewer-a.cer'),
      [prPrint, prPrint12bit, prExtractContent], rohSHA256, rohSHA256);
    Pdf.AddPubKeyRecipientCertificate(TFile.ReadAllBytes('reviewer-b.cer'),
      [prExtractContent]);
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 11);
    Pdf.CurrentPage.TextOut(72, 720, 0, 'Q3 audit pack');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

Три детали того листинга несут вес. Затравка — 20 байтов для каждого типа ключа, AES-256 включительно, и EnablePubKeyEncryption поднимает исключение на любой другой длине. Тип ключа по умолчанию aes128, тогда как оба сертификатных хелпера настаивают на aes256, поэтому пропуск второго аргумента заставит первый вызов AddPubKeyRecipientCertificate поднять "certificate envelopes require aes256". Устаревшие шифры (k40, k128, aes128) остаются доступны через AddPubKeyRecipient, который принимает полный DER-конверт, собранный сторонним инструментарием

Третья деталь — версия. AES-256 под обработчиком открытых ключей — фича PDF 2.0, и HotPDF поднимает версию документа до 2.0 сам. С выставленным StrictVersionLock на более низкой версии тот подъём не дозволен: EnablePubKeyEncryption возвращается, ничего не включив, и ошибка вылезает строкой позже, когда хелпер получателя рапортует "call EnablePubKeyEncryption first". То сообщение отправляет людей искать ошибку порядка вызовов. Проверьте вместо этого блокировку версии. Смена шифрования во время инкрементального обновления отвергается начисто с EInvalidOpException

Добавление получателей ECDH на P-256, P-384, P-521, X25519 и X448

Сертификаты на эллиптических кривых идут через AddPubKeyAgreementRecipientWithSecret, который пишет CMS-получателя key-agreement (KeyAgreeRecipientInfo из RFC 5753, с профилем X25519 и X448 из RFC 8418) и вычисляет общий секрет ECDH в процессе. Значение THPDFPubKeyAgreementScheme называет кривую: pkasECDHP256, pkasECDHP384, pkasECDHP521, pkasX25519 или pkasX448. Схема обязана совпадать с алгоритмом ключа сертификата, кривой и длиной ключа, иначе вызов поднимает "Certificate key does not match the requested agreement scheme"

Каждый конверт получает свежий случайный 32-байтовый UKM, key-encryption key из KDF stdDH (SHA-256 для P-256 и X25519, SHA-384 для P-384, SHA-512 для P-521 и X448) и key wrap AES-256 как определено в RFC 3394. Арифметика кривой — чистый Pascal без платформенного криптопровайдера под капотом; статья о NIST-кривой и Montgomery-арифметике на чистом Pascal проходит по тому, как этот слой собран. На Montgomery-кривых вся эфемерная пара ключей генерируется локально:

uses
  System.SysUtils, System.IOUtils, HPDFDoc, HPDFCrypt, HPDFPubSec,
  HPDFKeyAgreement;

procedure AddLegalRecipient(Pdf: THotPDF);
var
  Scalar, OriginatorPublic: TBytes;
begin
  // Свежий эфемерный скаляр на каждый конверт; clamping происходит внутри лестницы
  SetLength(Scalar, 32);
  AESGenerateRandomBytes(@Scalar[0], Length(Scalar));
  try
    OriginatorPublic := HPDFX25519PublicFromScalar(Scalar);
    Pdf.AddPubKeyAgreementRecipientWithSecret(
      TFile.ReadAllBytes('legal-x25519.cer'),
      [prPrint, prExtractContent], pkasX25519,
      OriginatorPublic, Scalar,
      []);   // OwnPublicPoint: значим только для NIST-кривых
  finally
    HPDFSecureClearBytes(Scalar);
  end;
end;

NIST-кривые спрашивают с вызывающего больше. HotPDF выводит открытые ключи инициатора только для X25519 и X448 (HPDFX25519PublicFromScalar, HPDFX448PublicFromScalar), поэтому для P-256, P-384 и P-521 эфемерная пара приходит из вашего собственного инструментария. Передайте big-endian скаляр ровно размера поля (32, 48 или 66 байтов) и соответствующую несжатую точку 0x04||X||Y (65, 97 или 133 байтов) как OriginatorPublicKey, и отдайте ту же точку в OwnPublicPoint — параметр, зарезервированный за NIST-схемами. Неверные длины поднимают исключение немедленно. Точка получателя, взятая из сертификата, сверяется с уравнением кривой прежде всякого умножения

Чего не проверит ни одна библиотека — принадлежит ли ваш открытый ключ инициатора вашему скаляру. Несовпавшие половины всё равно дают корректно выглядящий конверт. Писатель выводит ключ из скаляра, умноженного на точку получателя, получатель выводит свой из своего приватного ключа, умноженного на неверную точку инициатора, и integrity-проверка RFC 3394 на стороне получателя — первое, что это замечает, — но файл к тому моменту уже уехал. Round-trip загрузка с тестовым ключом на той же кривой обязана жить в вашей сборке; успешный EndDoc и правдоподобный размер файла здесь ничего не доказывают

Именно из-за шага спаривания выбор кривой имеет практическое значение. Если выдачу сертификатов контролируете вы, X25519 — более простая кривая, чтобы сделать всё правильно из Delphi: один случайный скаляр на 32 байта, один вызов хелпера, никакой точки, которую надо держать в синхроне руками. NIST-кривые заслуживают своё место, когда сертификаты получателей приходят из PKI, выпускающей только P-256 или P-384, и в таком случае round-trip загрузка перестаёт быть опциональной

Схема согласования ECDH в HotPDF: AddPubKeyAgreementRecipientWithSecret выводит общий секрет чистым Pascal-кодом кривых, вмешивает свежий 32-байтовый UKM через KDF stdDH с SHA-256 для P-256 и X25519, SHA-384 для P-384, SHA-512 для P-521 и X448, затем заворачивает ключ содержимого key wrap AES-256 из RFC 3394, собирая конверт KeyAgreeRecipientInfo
Значение схемы от pkasECDHP256 до pkasX448 обязано совпадать с ключом сертификата, а несовпавшие половины скаляра и открытой точки всё равно дают корректный конверт, который ни один получатель не откроет

Почему порядок /Recipients имеет значение?

Порядок /Recipients имеет значение, потому что ключ файла — дайджест над затравкой, за которой следует каждый конверт в порядке массива, поэтому писатель и читатель обязаны хешировать идентичные байты в идентичной последовательности. HotPDF держит конверты в порядке добавления и пишет их неизменными, так что любой порядок вы вольны выбирать. Что случается с массивом потом — рискованная часть. Постпроцессинг, конвейеры подписания и самодельные сборщики конвертов — вот где это ломается, и провалы делят одну форму: две стороны хешируют чуть разные байты:

  • Нетипизированный TList Delphi хранит сырой указатель. Сбор конвертов через List.Add(Pointer(Bytes)) оставляет счётчик ссылок локальной переменной, поэтому следующий SetLength на той переменной освободит или переиспользует буфер, и каждый слот может оказаться алиасом последнего конверта. TList<TBytes> или простой TArray<TBytes> держат ссылки посчитанными
  • DER-парсеры, работающие на месте, правят буфер, который разбирают. Хешируйте нетронутую копию каждого конверта, снятую прежде, чем хоть одна попытка распаковки её коснётся
  • Unicode TStringList — не место для бинарного DER: байты от $80 и выше перекодируются кодовой страницей на входе. Держите конверты в TBytes или сперва hex-кодируйте их
  • Шифртексту место в hex-строках. Внутри literal-строки CR, LF и CRLF все читаются обратно как одиночный LF (ISO 32000-1 §7.3.4.2), поэтому конверт, в котором случайно оказалось $0D $0A, теряет байт. HotPDF пишет каждую запись /Recipients hex-строкой и оставляет её вне строкового шифрования, ведь ридеру конверты нужны раньше, чем у него появится хоть какой-то ключ
  • Первый байт содержимого DER BIT STRING считает неиспользованные биты и обязан быть $00 для ключей, выровненных по байтам. Рукописный DER, выронивший этот байт и положивший точку 0x04||X||Y прямо в содержимое, сдвигает весь ключ на байт, и строгий парсер отвергает ключ инициатора целиком
  • Когда ключ, который должен работать, всё равно не расшифровывает, сравнивайте послойно, в этом порядке: ключ файла, IV в голове шифртекста, ключ на объект, открытый текст. Неисправность сидит сразу за первым слоем, где разошлись

Загрузка зашифрованного по сертификатам PDF с приватными ключами

Приватные ключи обязаны быть зарегистрированы до того, как выполнится LoadFromFile, потому что HotPDF восстанавливает ключ файла во время структурного прохода. RSA-идентичности, разобранные HPDFParsePFX, идут в PubSecKeyMaterial, а дальнейшие RSA-ключи — в AddPubSecKeyMaterial, который отвергает материал без модуля и приватной экспоненты. Оба тех слота ключей сопоставляются по модулю RSA и приватной экспоненте, поэтому EC-ключи, PFX-ные включительно, идут третьим маршрутом: AddPubSecAgreementKeyMaterial(CurveOID, PrivateScalar, OwnPublicPoint) с одной из констант HPDFOIDX25519, HPDFOIDX448, HPDFOIDECP256, HPDFOIDECP384 или HPDFOIDECP521. NIST-кривые требуют там несжатую открытую точку самого получателя; Montgomery-кривые её игнорируют

EC-идентичность, приехавшая PFX-файлом, ложится в тот же вызов. HPDFParsePFX рапортует о ней с KeyAlgorithm = pfxkaECDSA (включая идентичности X25519 и X448) и заполняет CurveOID, приватный скаляр в PrivateKey и точку сертификата в PublicKey, что ложится прямо на три параметра

uses
  System.SysUtils, System.IOUtils, HPDFDoc, HPDFPFX, HPDFKeyAgreement;

procedure OpenAuditPack(const LegalScalar: TBytes);
var
  Reader: THotPDF;
  EcKey: THPDFPFXKeyMaterial;
begin
  Reader := THotPDF.Create(nil);
  try
    Reader.AutoLaunch := False;
    // RSA-идентичность становится первичным ключом
    Reader.PubSecKeyMaterial :=
      HPDFParsePFX(TFile.ReadAllBytes('reviewer-a.pfx'), 'pfx-password');
    // EC-идентичность из PFX: кривая, скаляр и точка идут в список согласования
    EcKey := HPDFParsePFX(TFile.ReadAllBytes('reviewer-c-p256.pfx'), 'pfx-password');
    if EcKey.KeyAlgorithm = pfxkaECDSA then
      Reader.AddPubSecAgreementKeyMaterial(EcKey.CurveOID,
        EcKey.PrivateKey, EcKey.PublicKey);
    // Сырой скаляр X25519 у юристов; точка не нужна
    Reader.AddPubSecAgreementKeyMaterial(HPDFOIDX25519, LegalScalar, nil);
    Reader.LoadFromFile('audit-pack.pdf', '');
    Writeln('Pages: ', Reader.GetLoadedPageCount);
  finally
    Reader.Free;
  end;
end;

Без вашего участия HotPDF пробует каждый конверт против каждого зарегистрированного ключа: первичного RSA, затем каждого дополнительного RSA, затем EC-материала. PubSecRecipientQuery сужает это до одного конверта. Колбэк получает счётчик конвертов и обязан вернуть 0-базный индекс внутри массива; любое другое значение, включая -1, поднимает исключение во время загрузки. Чтобы пробовать каждый конверт, оставьте свойство неназначенным. В Delphi тип — reference to function, поэтому работает анонимный метод, а в FPC это простой указатель на функцию, чей аргумент Context приходит из PubSecRecipientQueryContext

Одно поведение заслуживает отдельного теста. Когда ни один зарегистрированный ключ не распаковывает ни один конверт, восстановление ключа заканчивается без ключа файла и не поднимает исключения по этой причине. Проверяйте утверждение о содержимом, которое документ точно держит, — текстовой строке или значению метаданных; вызов LoadFromFile, вернувшийся нормально, показывает лишь то, что структура файла разобралась

Схема загрузки приватных ключей в HotPDF: PubSecKeyMaterial несёт первичный RSA-ключ из HPDFParsePFX, AddPubSecKeyMaterial добавляет только RSA-ключи, AddPubSecAgreementKeyMaterial регистрирует сырые ECDH-скаляры под OID кривых от HPDFOIDX25519 до HPDFOIDECP521, а при LoadFromFile провайдер пробует первичный ключ, затем каждый дополнительный RSA-ключ, затем EC-материал против каждого конверта
Когда ни один ключ не распаковывает ни один конверт, шаг восстановления возвращается без ключа файла, а не поднимает исключение, поэтому проверяйте, что содержимое реально расшифровалось, или приколотите конверт через PubSecRecipientQuery

Что стоит проверить в целевом просмотрщике?

Писатель и читатель HotPDF согласны байт в байт, и конверты следуют цитированным выше структурам CMS. Сторонние просмотрщики — другой вопрос: поддержка key transport RSA-OAEP, и тем более получателей X25519 или X448, разнится между ридерами и между версиями одного ридера. Если документ обязан открываться в конкретном просмотрщике, зашифруйте тестовый файл под тестовый сертификат того же типа ключа и откройте его там, прежде чем связываться со схемой. Когда просмотрщик вне вашего контроля, его список поддержки выбирает схему, и это перевешивает аргумент об удобстве X25519, сделанный выше

Разрешения конвертов остаются политикой, которую соблюдающее ПО уважает, — ровно как и под шифрованием паролем. Качество затравки на вас: для той работы существует AESGenerateRandomBytes, и HotPDF стирает собственную копию затравки, как только ключ файла выведен. Если строке, потоку или вложению нужен другой crypt filter, руководство по политикам crypt filter для StmF, StrF и EFF показывает, как его назначить; обработчик открытых ключей принимает DefaultCryptFilter или Identity

Конверты по получателям для сертификатов RSA и эллиптических кривых, вместе с загрузкой ключей на обратном пути, — часть HotPDF PDF component для Delphi и C++Builder, рядом с шифрованием паролем и цифровыми подписями