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

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

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

Use case-ът е винаги някаква версия на същата история. Тримесечно одитно досие отива при трима външни ревюори, legal иска всеки от тях да го прочете, само един може да го отпечата, а никой не иска парола да седима в имейл нишка до attachment-а. Паролното шифроване не може да изрази това. Сертификатното може, защото всеки получател отключва документа с ключ, който вече държи, а всеки получател може да носи различен набор права в собствения си плик

Как шифроването на PDF със сертификати се различава от парола?

PDF, шифрован с публичен ключ, извлича файловия си ключ от случайно seed плюс точните байтове на всеки плик на получател, а не от нещо, което човек пише. Handler-ът е описан в 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 байта: seed-ът от 20 байта, следван от 32-битовата дума права на съответния получател. Стойността /P в encryption речника е само placeholder, защото истинските права пътуват във всеки плик. При зареждане четец разгръща един плик, възстановява seed-а и хешира seed-а заедно с всеки плик в реда на /Recipients (SHA-256 за AES-256, SHA-1 за по-старите шифри), за да преизгради файловия ключ. Ако все още решавате между този модел и обикновени пароли, ръководството за AES-256 паролно шифроване и permission флагове покрива другата страна на този компромис

Диаграма на public-key шифроването в HotPDF: EnablePubKeyEncryption фиксира seed от 20 байта, всеки CMS EnvelopedData плик шифрова тези 20 байта плюс една 32-битова дума права във /Filter /Adobe.PubSec с /SubFilter /adbe.pkcs7.s5 и /CFM /AESV3, а четецът разгръща един плик, възстановява seed-а и го хешира с всеки запис в /Recipients в реда на масива, за да преизгради файловия ключ
Стойността /P в encryption речника е само placeholder, защото истинските права пътуват във всеки плик, а нищо надолу по веригата няма право да пренареди или прекодира масива, върху който минава дайджестът

Писане на RSA получатели с EnablePubKeyEncryption

За RSA сертификати викнете EnablePubKeyEncryption с aes256, после викайте AddPubKeyRecipientCertificate по веднъж на DER-кодиран сертификат преди BeginDoc. Helper-ът строи 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
    // Ревюор А може да печата; ревюор Б само да чете и извлича
    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;

Три подробности в този листинг са товароносещи. Първо, дължината на seed-а е фиксирана на 20 байта за всеки тип ключ, включително AES-256; EnablePubKeyEncryption вдига грешка при всяка друга дължина. Второ, EnablePubKeyEncryption подразбира aes128, а двата certificate helper-а отказват да работят, освен ако типът ключ не е aes256, така че забравеният втори аргумент ви връща изключението „certificate envelopes require aes256". Legacy шифрите (k40, k128, aes128) пак работят, но само през AddPubKeyRecipient с плик, изграден другаде. Трето, public-key шифроването с AES-256 е feature на 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". Под капака всеки плик получава свеж случаен UKM от 32 байта, key-encryption ключ, извлечен със stdDH KDF (SHA-256 за P-256 и X25519, SHA-384 за P-384, SHA-512 за P-521 и X448), и AES-256 key wrap по RFC 3394. Самата споделена тайна идва от чист Pascal код за криви, без платформен crypto provider; статията за чист Pascal NIST крива аритметика обяснява как този слой е изграден и проверен. За Montgomery кривите целият ефемерен ключов чифт може да се генерира локално:

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

procedure AddLegalRecipient(Pdf: THotPDF);
var
  Scalar, OriginatorPublic: TBytes;
begin
  // Свеж ефемерен scalar за всеки плик; clamping-ът става вътре в ladder-а
  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 helper-и само за X25519 и X448 (HPDFX25519PublicFromScalar, HPDFX448PublicFromScalar), така че за P-256, P-384 и P-521 генерирате ефемерния ключов чифт с собствените си инструменти и подавате big-endian scalar с точно размера на полето (32, 48 или 66 байта) плюс съответстващата некомпресирана точка 0x04||X||Y като OriginatorPublicKey. HotPDF валидира точката на получателя срещу уравнението на кривата, но не може да провери, че вашият originator public key наистина принадлежи на вашия scalar. Разминати половини пак дават напълно коректно оформен плик, който никой получател не може да отвори — затова round trip зареждането си е част от тестовия ви набор, а не само проверка за размер на файла

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

Защо редът на /Recipients има значение?

Редът на /Recipients има значение, защото файловият ключ е дайджест над seed-а и всеки плик в реда на масива, така че writer и reader трябва да хешират едни и същи байтове в една и съща последователност. HotPDF пази пликовете в реда, в който ги добавяте, и ги записва непроменени, което значи, че може да добавяте получатели в какъвто ред искате, но нищо надолу по веригата няма право да пренарежда, прекодира или „разчиства" този масив. Повечето истински бъгове в тази зона бяха вариации на тази тема, при които две страни хешираха малко по-различни байтове:

  • Съхранение на dynamic масиви в TList през Add пази само суров указател, докато reference броилката остава при локалната променлива. Следващото SetLength освобождава буфера и може да го преизползва, така че всеки слот свършваше aliasing на последния плик, а файлове с много получатели извличаха грешен ключ. Поправката е да се съхранява собствено копие с List.Add(Pointer(System.Copy(Bytes)))
  • Разгръщането на пликове парсва DER на място, а пасът за възстановяване на ключа първоначално хешеше същите живи масиви. Четецът вече прави снимки на чисти копия на всеки плик, преди някое разгръщане да ги пипне, а дайджестът минава върху снимките
  • Бинарен DER, преминал през Unicode TStringList, получава байтове на $80 или над него прекодирани от code page-а, затова HotPDF пази пликовете вътрешно като hex текст
  • Шифрованите и бинарните низове трябва да се записват като hex низове. Литерален низ е подложен на end-of-line нормализация, при която CR, LF и CRLF стават един LF (ISO 32000-1 §7.3.4.2), а това тихо пренаписва ciphertext-а. HotPDF излъчва всеки запис в /Recipients като hex низ и го освобождава от string шифроване, защото всеки четец трябва да има пликовете, преди да държи какъвто и да е ключ
  • Първият байт на DER BIT STRING брои неизползваните битове и трябва да е нула за ключове, подравнени по байт. Оставен неинициализиран след SetLength, записваше каквото има на стека, а строг разгръщащ отхвърляше ключа на originator-а, така че файл понякога не можеше да се отвори с точно ключа, за който е бил написан
  • Когато същият ключ пак не може да дешифрира, сравнявайте слой по слой: файловия ключ, после ciphertext префикса (IV-то), после обектния ключ, после plaintext-а. Бъгът живее точно след първия слой, който не съвпада

Как отваряте шифрован със сертификат PDF с частен ключ?

За да отворите шифрован със сертификат PDF, регистрирайте материала с частния ключ, преди да викнете LoadFromFile, защото HotPDF възстановява файловия ключ по време на структурния проход. Задайте RSA или EC ключ, парснат с HPDFParsePFX, на PubSecKeyMaterial, добавяйте още RSA ключове с AddPubSecKeyMaterial, а сурови ECDH scalars регистрирайте с AddPubSecAgreementKeyMaterial(CurveOID, PrivateScalar, OwnPublicPoint), ползвайки константите HPDFOIDX25519, HPDFOIDX448, HPDFOIDECP256, HPDFOIDECP384 или HPDFOIDECP521. NIST кривите изискват собствения некомпресиран public point на получателя; Montgomery кривите го игнорират

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 scalars под кривите OIDs от HPDFOIDX25519 до HPDFOIDP521, а при LoadFromFile provider-ът пробва първичния ключ, после всеки допълнителен RSA ключ, после EC материала срещу всеки плик
Когато никой ключ не разгръща нито един плик, стъпката за възстановяване се връща без файлов ключ, вместо да вдига грешка, така че проверете дали съдържанието наистина се е дешифрирало или заковете плика чрез PubSecRecipientQuery

Какво HotPDF не гарантира

HotPDF гарантира, че собствените му writer и reader се съгласуват байт по байт, и строи пликове, следващи цитираните по-горе CMS структури. Не гарантира, че всеки PDF viewer отваря всяка комбинация. Поддръжката на key transport по RSA-OAEP и на получатели X25519 или X448 варира между четци и версии, а ние не сме публикували резултати за съвместимост за тези комбинации. Ако документът трябва да се отваря в конкретен viewer, шифровайте тестов файл за тестов сертификат от същия тип ключ и го отворете там, преди да се ангажирате със схема. Правата, носени в плика, остават политика, която конформен софтуер уважава — точно както при паролното шифроване. Качеството на seed-а също е ваша отговорност: AESGenerateRandomBytes е точно за тази работа, а HotPDF изтрива своето копие на seed-а, щом файловият ключ е извлечен. Ако освен това ви трябва string, stream или attachment да ползва друг crypt filter, ръководството за crypt filter политики за StmF, StrF и EFF показва кои имена на филтри приема public-key handler-ът

Сертификатното шифроване, пликовете за получатели RSA-OAEP и ECDH и зареждането на частни ключове излизат в HotPDF Delphi PDF компонента, редом с паролното шифроване, цифровите подписи и останалата част от ISO 32000 toolset-а за Delphi и C++Builder