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

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

HotPDF шифрует PDF под конкретных держателей сертификатов через public-key security handler ISO 32000: EnablePubKeyEncryption принимает 20-байтовую случайную затравку, а каждый получатель получает собственный CMS-конверт, который строит AddPubKeyRecipientCertificate для RSA-ключей (транспортировка ключа RSA-OAEP) или AddPubKeyAgreementRecipientWithSecret для ключей на эллиптических кривых (ECDH на P-256, P-384, P-521, X25519 или X448). Никто ни с кем не делится паролем; файл открывает тот, у кого есть подходящий приватный ключ

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

Чем шифрование 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-битное слово разрешений этого получателя. Значение /P в словаре шифрования — лишь заполнитель, потому что настоящие разрешения едут внутри каждого конверта. При загрузке ридер распаковывает один конверт, восстанавливает затравку и хеширует её вместе со всеми конвертами в порядке /Recipients (SHA-256 для AES-256, SHA-1 для старых шифров), чтобы собрать ключ файла заново. Если вы всё ещё выбираете между этой моделью и обычными паролями, гайд по 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 в собственном процессе со значениями THPDFRSAOAEPHash для дайджеста OAEP и дайджеста MGF1 (rohSHA256, rohSHA384 или rohSHA512) и шифрует содержимое конверта 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 поднимает исключение на любой другой длине. Вторая: EnablePubKeyEncryption по умолчанию ставит aes128, и оба сертификатных хелпера отказываются работать, пока тип ключа не aes256, так что забытый второй аргумент обернётся исключением «certificate envelopes require aes256». Устаревшие шифры (k40, k128, aes128) всё ещё работают, но только через AddPubKeyRecipient с конвертом, который вы построили где-то в другом месте. Третье: шифрование по открытому ключу с 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-получателя согласования ключей (KeyAgreeRecipientInfo, структура KARI из 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 на чистом Pascal. Для кривых Монтгомери вся эфемерная пара ключей генерируется локально:

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

procedure AddLegalRecipient(Pdf: THotPDF);
var
  Scalar, OriginatorPublic: TBytes;
begin
  // Свежий эфемерный скаляр на каждый конверт; клэмпинг происходит внутри лестницы
  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 как OriginatorPublicKey. HotPDF проверяет точку получателя по уравнению кривой, но не может проверить, что ваш originator public key действительно принадлежит вашему скаляру. Несовпавшие половины всё равно дают совершенно корректный конверт, который ни один получатель не откроет, — поэтому 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 через Add держит лишь сырой указатель, пока счётчик ссылок остаётся у локальной переменной. Следующий SetLength освобождает буфер и может переиспользовать его, так что каждый слот оказывался алиасом последнего конверта, и мультиполучательские файлы выводили неверный ключ. Починка — хранить собственную копию: List.Add(Pointer(System.Copy(Bytes)))
  • Распаковка конвертов разбирает DER на месте, и проход восстановления ключа поначалу хешировал те же живые массивы. Теперь ридер снимает нетронутые копии каждого конверта до того, как какая-либо распаковка их коснётся, а дайджест идёт по копиям
  • Бинарный DER, прогнанный через Unicode TStringList, получает перекодированные кодовой страницей байты от $80 и выше, поэтому HotPDF хранит конверты внутри как hex-текст
  • Зашифрованные и бинарные строки обязаны писаться hex-строками. Литеральную строку настигает нормализация конца строки, где CR, LF и CRLF превращаются в один LF (ISO 32000-1 §7.3.4.2), и это молча переписывает шифротекст. HotPDF выпускает каждую запись /Recipients hex-строкой и освобождает её от шифрования строк, ведь каждому ридеру конверты нужны до того, как у него появится хоть какой-то ключ
  • Первый байт DER BIT STRING считает неиспользованные биты и для выровненных по байту ключей обязан быть нулём. После SetLength он оставался неинициализированным и нёс то, что лежало на стеке, строгий анвраппер отвергал ключ инициатора, и файл мог изредка отказываться открываться тем самым ключом, под который был записан
  • Если тот же ключ всё ещё не расшифровывает, сравнивайте слой за слоем: ключ файла, затем префикс шифротекста (IV), затем ключ объекта, затем открытый текст. Баг живёт сразу за первым слоем, где разошлись

Как открыть PDF, зашифрованный по сертификату, приватным ключом?

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

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

procedure OpenAuditPack(const LegalScalar: TBytes);
var
  Reader: THotPDF;
begin
  Reader := THotPDF.Create(nil);
  try
    Reader.AutoLaunch := False;
    Reader.PubSecKeyMaterial :=
      HPDFParsePFX(TFile.ReadAllBytes('reviewer-a.pfx'), 'pfx-password');
    Reader.AddPubSecAgreementKeyMaterial(HPDFOIDX25519, LegalScalar, nil);
    // Опционально: выбрать конверт напрямую вместо перебора всех
    Reader.PubSecRecipientQuery :=
      function(Context: Pointer; RecipientCount: Integer): Integer
      begin
        Result := -1;   // -1 = пробовать каждый конверт по порядку
      end;
    Reader.LoadFromFile('audit-pack.pdf', '');
    Writeln('Pages: ', Reader.GetLoadedPageCount);
  finally
    Reader.Free;
  end;
end;

Без колбэка HotPDF пробует каждый конверт против каждого зарегистрированного ключа: сначала первичный ключ, затем каждый дополнительный RSA-ключ, затем EC-материал. PubSecRecipientQuery получает число конвертов и возвращает индекс с нуля или -1, а индекс вне массива поднимает исключение вместо обрезания. Учтите: AddPubSecKeyMaterial принимает только RSA-материал (упирается в модуль и приватную экспоненту), так что EC-ключам место в PubSecKeyMaterial или AddPubSecAgreementKeyMaterial. Когда ни один ключ не распаковывает ни один конверт, шаг восстановления возвращается без ключа файла, а не поднимает исключение, так что проверяйте, что ожидаемое содержимое реально расшифровалось, а не доверяйте факту возврата вызова загрузки

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

Чего HotPDF не гарантирует

HotPDF гарантирует, что его собственные писатель и читатель согласованы байт в байт, и строит конверты по цитированным выше CMS-структурам. Он не гарантирует, что каждый PDF-вьювер откроет каждую комбинацию. Поддержка транспортировки ключей RSA-OAEP и получателей X25519 или X448 гуляет от ридера к ридеру и от версии к версии, и результатов совместимости для этих комбинаций мы не публиковали. Если документ обязан открываться в конкретном вьювере, зашифруйте тестовый файл под тестовый сертификат того же типа ключа и откройте его там, прежде чем останавливаться на схеме. Разрешения, едущие в конверте, остаются политикой, которую соблюдает соответствующее ПО, ровно как и при парольном шифровании. Качество затравки — тоже ваша ответственность: для этого и существует AESGenerateRandomBytes, а HotPDF затирает свою копию затравки, как только ключ файла выведен. Если строке, потоку или вложению нужен другой crypt filter, гайд по политикам crypt filter для StmF, StrF и EFF показывает, какие имена фильтров принимает public-key хендлер

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