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

Шифрування 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 із public-key-шифруванням виводить свій файловий ключ із випадкового сіда плюс точних байтів кожного конверта отримувача, а не з того, що хтось набирає руками. Обробник описаний в 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-шифрування паролем і прапорці дозволів покриває інший бік того розміну

Діаграма public-key-шифрування 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 public-key-шифрування — фіча 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, структура 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, ключ шифрування ключа, виведений KDF stdDH (SHA-256 для P-256 і X25519, SHA-384 для P-384, SHA-512 для P-521 і X448), і AES-256 key wrap за RFC 3394. Сам спільний секрет виходить із чистого Pascal-коду кривих, без жодного платформового crypto-провайдера; стаття про арифметику кривих NIST у чистому Pascal пояснює, як той шар будували і перевіряли. Для кривих Монтгомері вся ефемерна ключова пара може генеруватися локально:

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 постачає public-key-хелпери лише для 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 крізь stdDH KDF з SHA-256 для P-256 і X25519, SHA-384 для P-384, SHA-512 для P-521 і X448, а потім загортає ключ вмісту в AES-256 key wrap з 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, він писав те, що лежало на стеку, і суворий розгортач відкидав ключ originator-а, тож файл час від часу відмовлявся відкриватися тим самим ключем, під який був записаний
  • Коли той самий ключ усе одно не може розшифрувати, порівнюйте шар за шаром: файловий ключ, потім префікс шифротексту (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;

Без callback-а 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