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

Ви не можете мовчки латати зашифрований PDF у Delphi

Візьміть PDF рахунку-фактури, що вже несе шифрування AES-256, і попросіть компонент PDFium для Delphi та C++Builder (PDFiumPas) проштампувати його PDF/A для архівного зберігання чи підписати його PAdES через інкрементальне оновлення, а не повний перепис. Бібліотека не досягне цього, латаючи зашифровані байти напряму: її шість ін'єкторів маркерів відповідності виявляють наявний запис /Encrypt і пропускають джерело до призначення побайтово незмінним, а її підписувач PAdES піднімає виняток замість того, щоб видати підпис, який жоден валідатор не прийме

Це інше питання, ніж аудит PDF, який ви не створювали, на приховані ризики, що є власною вправою лише для читання. Ця стаття про бік запису тієї самої межі довіри: що вашому власному коду дозволено робити з файлом, чиї байти вже заблоковані за чиїмось паролем, тієї миті, коли цей код намагається щось додати до нього постфактум

Що вимагає ISO 32000-1, коли ви оновлюєте зашифрований PDF?

ISO 32000-1 §7.5.6 вимагає, щоб трейлер інкрементального оновлення повторював кожен запис із попереднього трейлера, крім /Prev, а таблиця 15 перелічує /Encrypt серед записів, які трейлер може нести. Приберіть його з нового трейлера, і читач, що відповідає специфікації, не має підстав засумніватися в цьому пропуску: найновіший трейлер авторитетний, тож читач, що не знаходить там /Encrypt, вирішує, що весь файл нешифрований, і намагається розібрати старіше, усе ще зашифроване тіло як звичайні байти. Залиште /Encrypt у новому трейлері, але запишіть власні об'єкти оновлення відкритим текстом, і збій просто зсувається на крок пізніше: читач правильно виявляє шифрування, проганяє кожен об'єкт, якого торкається, через шифр файлу, включно з новими, які взагалі ніколи не шифрувалися, і отримує шум назад для вмісту, що був цілком читабельним, поки дешифрування його не торкнулося. Будь-яка з цих помилок виробляє файл, що на рівні байтів виглядає як нормальне, коректно сформоване інкрементальне оновлення, аж доки його не відкриє читач, що відповідає специфікації

Діаграма за ISO 32000-1, 7.5.6 для інкрементних оновлень зашифрованого PDF у Delphi: новий трейлер, що втрачає /Encrypt, змушує читача розбирати шифротекст як звичайні байти; об'єкти оновлення у відкритому тексті перемішуються файловим шифром, а повторення /Encrypt із зашифрованими новими об'єктами — єдиний результат, що відповідає стандарту
Трейлер оновлення, що впустив /Encrypt, змушує читача розбирати шифротекст як звичайні байти, а незашифровані об'єкти оновлення перемішуються файловим шифром — лише повторення /Encrypt та шифрування нових об'єктів переживає відповідний стандарту читач

Шість ін'єкторів маркерів, один шлюз шифрування v2.14.2

PDFiumPas постачає шість ін'єкторів маркерів на рівні байтів, по одному на кожен підмножину PDF ISO, яку може позначити: PDF/A (ISO 19005), PDF/X (ISO 15930), PDF/UA (ISO 14289-1), PDF/E-1 (ISO 24517-1), PDF/R-1 (ISO 23504-1) та PDF/VT-1 (ISO 16612-2). Кожен бере байти, які вже записав власний FPDF_SaveAsCopy у PDFium, і накладає на них другий, менший інкрементальний оновлення: новий потік метаданих XMP, редагування словника каталогу, що на нього вказує, а для орієнтованих на друк підмножин — OutputIntent та профіль ICC. Станом на v2.14.2, кожен з InjectPdfAMarkers, InjectPdfXMarkers, InjectPdfUaMarkers, InjectPdfEMarkers, InjectPdfRMarkers та InjectPdfVTMarkers спочатку читає вихідний трейлер, і якщо той повідомляє про наявний запис /Encrypt, копіює джерело до потоку призначення немодифікованим і негайно повертається. Ні XMP, ні OutputIntent, ні редагування каталогу — викликач отримує назад оригінальний файл, байт у байт

var
  Src, Dst: TFileStream;
  Opts: TPdfXSaveOptions;
begin
  Src := TFileStream.Create('signed-encrypted-proof.pdf', fmOpenRead);
  Dst := TFileStream.Create('pdfx-attempt.pdf', fmCreate);
  try
    Opts.Conformance := pxc4;
    InjectPdfXMarkers(Src, Dst, Opts);
    // pdfx-attempt.pdf побайтово ідентичний джерелу: все ще зашифрований,
    // без /GTS_PDFXVersion, без OutputIntent. Нічого не було записано, і
    // нічого не було пошкоджено
  finally
    Dst.Free;
    Src.Free;
  end;
end;

Дозволено бути зашифрованим — не те саме, що безпечно вводити всередину

PDF/E-1 та PDF/R-1 обидва явно дозволяють своєму документу-хосту бути зашифрованим на рівні специфікації, що читається як звільнення, доки не подивишся, що насправді має відбутися на диску. ISO 24517-1 §6.3 дозволяє шифрування для PDF/E-1, а ISO 23504-1 §6.2.3 дозволяє його для PDF/R-1 за умови, що заголовок оголошує %PDF-2.0. Жоден пункт нічого не каже про те, чи може постпроцесор на рівні байтів безпечно додати об'єкт відкритого тексту в цей зашифрований контейнер, а він не може, з тих самих причин §7.5.6, що застосовуються до кожної іншої підмножини. Власні валідатори відповідності PDFiumPas для цих двох профілів, ValidatePdfECompliance та ValidatePdfRCompliance, реєструють присутність /Encrypt навмисно, не позначаючи це як дефект, що правильно для валідатора лише для читання, який ніколи не записує жодного байта. Це також легкий для побіжного пропуску шаблон, що змушує припустити, ніби суміжному ін'єктору не потрібна окрема охорона, тоді як ін'єктор — та єдина функція в парі, яка справді мусить відмовити

Чи мовчки дешифрує SaveAsPdfX ваш документ?

Так, щоразу коли ви йдете через публічні зручні методи замість прямого виклику ін'єктора. Кожен з TPdf.SaveAsPdfA, SaveAsPdfX, SaveAsPdfUa, SaveAsPdfE, SaveAsPdfR та SaveAsPdfVT рендерить поточний документ у тимчасовий потік через SaveAs(Tmp, saRemoveSecurity), перш ніж передати ці байти відповідному ін'єктору. saRemoveSecurity відображається на власний прапорець FPDF_REMOVE_SECURITY PDFium, тож тимчасова копія, яку отримує ін'єктор, взагалі ніколи не була зашифрованою, і охорона /Encrypt ін'єктора ніколи не має причин спрацювати. Вихід несе ваші маркери PDF/A, PDF/X, PDF/UA, PDF/E-1, PDF/R-1 чи PDF/VT-1, але вже не захищений тим паролем, що відкривав джерело

Цей компроміс невидимий, доки хтось вниз за течією не відкриє «захищену» архівну копію без пароля й не помітить, що вона просто працює. Виправлення — не інший виклик методу; PDFiumPas не має аналога saAddSecurity, щоб парюватися з saRemoveSecurity, бо базовий рушій PDFium ніколи не будувався для запису нового шифрування, лише для його видалення. Якщо обидві властивості важать для одного файлу, шифрування має бути окремим кроком, яким ви володієте, застосованим після маркерів відповідності, а не згорнутим у той самий виклик SaveAsPdfA

var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.Password := 'open-secret';           // потрібен, щоб взагалі відкрити джерело
    Pdf.FileName := 'signed-encrypted-invoice.pdf';
    Pdf.LoadDocument;
    Pdf.SaveAsPdfA('invoice-pdfa.pdf', pac2b);
    // invoice-pdfa.pdf тепер оголошує PDF/A-2b, але SaveAs(saRemoveSecurity)
    // виконався спочатку всередині SaveAsPdfA: вихід відкривається без пароля
  finally
    Pdf.Free;
  end;
end;

Що відбувається, коли ви підписуєте зашифрований PDF за допомогою PAdES?

PDFiumPas відверто відмовляє, а не мовчки скидає запит так, як це робить ін'єктор маркерів. TPdf.SignPades та SignPadesToStream обидва проходять через внутрішній SignPadesBytes, і перше, що він робить після розбору вихідного трейлера, — перевіряє на /Encrypt. Якщо запис присутній, він піднімає EPadesCrypto з повідомленням "SignPadesBytes: the source document is encrypted; remove encryption before signing" замість того, щоб продовжувати далі. InjectPadesDssMarkers, функція, що вбудовує сертифікати, відповіді OCSP та CRL для довгострокової валідації, застосовує ідентичну перевірку з тієї самої причини, з власним повідомленням: "InjectPadesDssMarkers: the source document is encrypted; remove encryption before embedding DSS validation material"

Шлюз шифрування PDFium Component у Delphi: всі шість вприскувачів маркерів читають вихідний трейлер; запис /Encrypt пропускає байти без змін, тоді як незашифрований файл отримує маркери XMP, catalog і OutputIntent, а підписувач PAdES піднімає EPadesCrypto замість пропускання
Усі шість інжекторів і PAdES-підписувач спершу читають вихідний трейлер — зашифрований вхід проходить недоторканим, незашифрований отримує свої маркери, а підписування відмовляє з EPadesCrypto замість тихого проходу

Міркування тут суворіше за прохідний пропуск ін'єкторів маркерів, і навмисно так. Тихий прохідний пропуск безпечний для штампу PDF/A, бо його пропуск залишає вас із тим самим дійсним PDF, з яким ви почали, просто непозначеним. Підписування не може провалитися так само тихо: підпис, що мовчки ніколи не був доданий, виглядає для будь-якого коду, що викликає й перевіряє лише булевий результат, точно так само, як успішно доданий підпис. EPadesCrypto походить від звичайного класу Exception, тож його перехоплення — звичайна обробка винятків, а не спеціальна конвенція керування потоком виконання, яку доведеться вивчати

var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.Password := 'open-secret';
    Pdf.FileName := 'encrypted-contract.pdf';
    Pdf.LoadDocument;
    try
      Pdf.SignPades('encrypted-contract-signed.pdf', 'A1B2C3D4E5F6...');
    except
      on E: EPadesCrypto do
        // E.Message: 'SignPadesBytes: the source document is encrypted;
        // remove encryption before signing'
        raise Exception.Create('Decrypt the contract before signing: '+ E.Message);
    end;
  finally
    Pdf.Free;
  end;
end;

Послідовність штампів відповідності, підписів та шифрування

Практичне виправлення — порядок, не інша бібліотека. Спочатку застосуйте маркери PDF/A, PDF/X, PDF/UA, PDF/E-1, PDF/R-1 чи PDF/VT-1, далі додайте будь-який підпис PAdES, і лише тоді запустіть той крок вашого конвеєра, що справді володіє шифруванням, чи то виділений записувач PDF, підписувальний пристрій, чи ваша власна реалізація AES. Шар інкрементальних оновлень PDFiumPas природно вписується в середину цієї послідовності, додаючи невеликі, цільові об'єкти до файлу, що в іншому випадку завершений, а шифрування належить у кінець саме тому, що це та одна операція в ланцюжку, яку сам PDFiumPas не може виконати чи скасувати

Порядок конвеєра Delphi для PDFiumPas на зашифрованих PDF: маркери відповідності вприскуються першими, поки файл незашифрований; далі додається підпис PAdES, а шифрування йде останнім як окремий крок, яким володіє конвеєр, бо PDFiumPas не може його застосувати чи зняти
PDFiumPas дописує маркери відповідності та PAdES-підписи, поки файл ще читаєми, а шифрування йде останнім як окремий крок, яким володіє конвеєр

Ніщо з цього не змінює, як PDFiumPas читає дані трейлера та перехресних посилань, від яких залежить кожне інкрементальне оновлення, а це власне джерело тонкощів, щойно в кадр потрапляють потоки xref; перевірка потоків об'єктів та xref PDF розглядає, як той самий шлях читання трейлера обробляє стиснуті структури PDF 1.5+. А щойно документ готовий до чогось сильнішого за штамп відповідності, підписування PDF підписом PAdES B-B у Delphi — місце, де SignPades переймає естафету рівно з тієї точки, де ця стаття зупиняється

Ін'єктори маркерів та методи SignPades, описані тут, постачаються як частина компонента PDFium для Delphi та C++Builder, поряд з рендерингом та інспекцією лише для читання, які PDFium надає нативно