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

Флаги разрешений PDF, целое /P и копия /Perms

Вы шифруете отчёт с маской, которая должна была разрешать всё, — $FFFFFFF0, — а вкладка Security в Acrobat перечисляет печать и изменение документа как запрещённые. /P — беззнаковое 32-битное слово флагов с нумерацией от бита 1, так что эта маска стирает печать (бит 3) и изменение (бит 4); разрешающее всё значение — $FFFFFFFC, стартовая точка TPdfEncryptOptions в PDFium Component

Как уложено слово флагов /P

Смысл несут только восемь позиций, остальные зарезервированы, а счёт идёт с 1. ISO 32000-1 §7.6.3.2 требует толковать P как беззнаковое 32-битное количество («shall be interpreted as an unsigned 32-bit quantity»), нумерованное от 1 на младшем конце до 32 на старшем, и велит соответствующим читателям игнорировать любой бит, кроме 3, 4, 5, 6, 9, 10, 11 и 12. ISO 32000-2 §7.6.4.2 правит Table 22 и проговаривает зарезервированные позиции: биты 1–2 должны быть 0, биты 7–8 должны быть 1, а для revision 3 и старше биты 13–32 должны быть 1. PDF 2.0 заодно отправляет на пенсию бит 10: читатели теперь его игнорируют, а писатели всегда ставят 1 ради старых читателей; исключение для доступности переезжает в бит 5, так что читатель должен действовать, как будто копирование разрешено, когда контент идёт в assistive technology. Одна формула покрывает каждую строку ниже. Маска для бита n — 1 shl (n - 1), и TPdfPermission ложится на слово точно так же

БитМаскаЗначение (revision 3 и старше)TPdfPermission
1–2$00000003Зарезервировано, должно быть 0нет
3$00000004Печать, с качеством, зависящим от бита 12permPrint
4$00000008Изменение содержимого операциями, кроме тех, что у битов 6, 9 и 11permModify
5$00000010Копирование или извлечение текста и графикиpermCopy
6$00000020Добавление или изменение аннотаций, заполнение полей; с битом 4 — создание полейpermAnnotate
7–8$000000C0Зарезервировано, должно быть 1нет
9$00000100Заполнение существующих полей форм, включая поля подписи, даже если бит 6 сброшенpermFillForms
10$00000200Извлечение для доступности; в PDF 2.0 не используется, всегда пишется как 1permAccessibility
11$00000400Сборка: вставка, поворот или удаление страниц, создание пунктов оглавления и миниатюрpermAssemble
12$00000800Печать в точном, качественном представленииpermPrintHighQuality
13–32$FFFFF000Зарезервировано, должно быть 1 (ISO/TS 32004 назначает бит 13, см. ниже)нет

Маска $FFFFFFF0 рождается из прочтения «младшие биты зарезервированы» и перестраховочного стирания целого ниббла. Зарезервированная область — это только биты 1–2, маска $3; стирание $F заодно сносит $4 и $8 — печать и изменение, — и любой viewer, соблюдающий флаги, откажет в обоих. Зеркальная ошибка — собирать маску вверх с нуля: OR на $4, $10 и $800 даёт маленькое положительное /P со сброшенными битами 7–8 и 13–32. Большинство читателей откроют такой файл молча, потому что спецификация велит игнорировать зарезервированные биты, но значение не соответствует revision 3 и старше, и строгий проверяющий вправе пожаловаться. Третий вариант встречается в коде, считающем биты с нуля: «бит 3» превращается в $8, и программа разрешает изменение, когда имела в виду печать. Ничто из этого не даёт ошибки при сохранении. Разрешения просто выходят не теми, что задумывал автор

Раскладка слова флагов разрешений /P для PDFium Component на Delphi: зарезервированные биты 1-2, 7-8 и 13-32, биты TPdfPermission вроде permPrint и permModify и маски $FFFFFFFC и $FFFFFFF0
Стирание целого младшего ниббла через $FFFFFFF0 заодно убирает печать и изменение, а сборка маски с нуля оставляет сброшенными зарезервированные единицы revision 3 и старше

Равно ли /P -4 значению /P 4294967292?

Да. Те же 32 бита, два написания. Целые объекты PDF знаковые, и примечание прямо над Table 22 указывает: поскольку старшие зарезервированные биты равны 1, целое записывается отрицательным числом; -4 — условное разрешающее-всё значение для revision 3 и старше. Производители, держащие значение в беззнаковой переменной, пишут 4294967292, и оба написания встречаются в реальных файлах. Спецификация решает, какое чтение правильное, в собственном выводе ключа: шаг (d) Algorithm 2 из ISO 32000-1 §7.6.3.3 превращает P в 32-битное беззнаковое двоичное число и передаёт четыре байта в MD5 младшим байтом вперёд, так что для revision 2–4 значения -3904 и 4294963392 дают один ключ файла. Парсер, хранящий /P в знаковом 32-битном Integer, отвергает беззнаковое написание как переполнение; поменяйте переменную на Cardinal — и откажет уже отрицательное написание, а код, сравнивающий текст токена с сохранённым значением, под конец считает один документ двумя. Читайте токен в Int64, принимайте что угодно от Low(Integer) до High(Cardinal) и маскируйте до 32 бит перед любым сравнением. Собственный код расшифровки PDFium Component читает /P так для revision 2–7, а его писатель испускает знаковую форму из примечания спецификации. Отчёт о зарезервированных битах в сниппете останавливается на бите 14: сброшенный бит 13 — так ISO/TS 32004 помечает файл, несущий PDF MAC

uses
  System.SysUtils;

// Нормализуйте текст токена /P из собственного парсера словаря
function TryNormalizePermissions(const Token: string;
  out Mask: Cardinal): Boolean;
var
  Value: Int64;
begin
  Result := TryStrToInt64(Token, Value) and
    (Value >= Low(Integer)) and (Value <= High(Cardinal));
  if Result then
    Mask := Cardinal(Value and $FFFFFFFF);
end;

function DescribeReservedBits(Mask: Cardinal): string;
begin
  Result := '';
  if (Mask and $00000003) <> 0 then
    Result := Result + 'bits 1-2 set; ';
  if (Mask and $000000C0) <> $000000C0 then
    Result := Result + 'bits 7-8 clear; ';
  // Бит 13 ($1000) пропущен: ISO/TS 32004 сбрасывает его, требуя PDF MAC
  if (Mask and $FFFFE000) <> $FFFFE000 then
    Result := Result + 'bits 14-32 not all set; ';
end;

// Ожидание: оба вызова дают $FFFFFFFC и пустой отчёт о зарезервированных битах
//   TryNormalizePermissions('-4', M1);
//   TryNormalizePermissions('4294967292', M2);
Нормализация значения /P PDF на Delphi, как это делает PDFium Component: /P -4 и /P 4294967292 читаются в Int64, проверяются на диапазон от Low(Integer) до High(Cardinal) и маскируются в Cardinal $FFFFFFFC
Парсер знакового Integer и парсер Cardinal каждый отвергает по одному легальному написанию /P, а чтение в Int64 с последующей маской до 32 бит сводит оба написания к одному значению

Собираем маску из набора TPdfPermissions

Стартуйте с каждым зарезервированным битом в требуемом значении и OR-ьте только то, что разрешаете, — тогда забытое разрешение способно обернуться лишь отказом. TPdfEncryptOptions.Default в юните FPdfEncrypt уже держит $FFFFFFFC, разрешающий всё, и стереть с него один-два бита — нормально для быстрого ограничения. Политика, перечисляющая, что позволено получателям, читается лучше как набор, и константный массив ниже выстроен по позициям битов, которыми TPdf декодирует слово. Форсируйте бит 10, что бы ни передал вызывающий. ISO 32000-2 велит писателям всегда его ставить, читатели PDF 2.0 всё равно его игнорируют, а сброшенный бит рискует заблокировать screen reader в старом софте

uses
  System.SysUtils, PDFium, FPdfEncrypt;

const
  PermissionBit: array[TPdfPermission] of Cardinal = (
    $0004,   // permPrint             бит 3
    $0008,   // permModify            бит 4
    $0010,   // permCopy              бит 5
    $0020,   // permAnnotate          бит 6
    $0100,   // permFillForms         бит 9
    $0200,   // permAccessibility     бит 10
    $0400,   // permAssemble          бит 11
    $0800);  // permPrintHighQuality  бит 12

  // Биты 7-8 и 13-32 взведены; биты 1-2 и все биты разрешений сброшены
  ReservedOnes = Cardinal($FFFFF0C0);

function PermissionsToMask(Allowed: TPdfPermissions): Cardinal;
var
  P: TPdfPermission;
begin
  Result := ReservedOnes or PermissionBit[permAccessibility];
  for P in Allowed do
    Result := Result or PermissionBit[P];
end;

procedure SaveFillAndPrintCopy(const Source, Target: string;
  const OwnerPassword: AnsiString);
var
  Pdf: TPdf;
  Opts: TPdfEncryptOptions;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := Source;
    Pdf.Active := True;
    if not Pdf.Active then
      raise Exception.CreateFmt('Could not open %s', [Source]);

    Opts := TPdfEncryptOptions.Default;   // здесь Revision = erR5
    Opts.UserPassword := '';              // открыть файл может любой
    Opts.OwnerPassword := OwnerPassword;  // охраняет набор разрешений
    Opts.Revision := erR6;
    Opts.Permissions := PermissionsToMask(
      [permPrint, permPrintHighQuality, permFillForms]);

    // Ожидание: /P -1084 = 4294966212 ($FFFFFBC4)
    Writeln('/P ', Integer(Opts.Permissions), ' = ', Opts.Permissions);

    if not Pdf.SaveAsEncrypted(Target, Opts) then
      raise Exception.Create('Encrypted save failed');
  finally
    Pdf.Free;
  end;
end;

Ожидание: словарь Encrypt выхода несёт /P -1084, а соблюдающий флаги viewer предлагает печать в полном качестве и заполнение форм, притушив правки содержимого, копирование, комментирование и сборку страниц. Бит 13 — одна позиция, которую писатель ведёт за вас. При EnableIntegrityProtection, оставленном в False, SaveAsEncrypted ставит бит 13, что бы ни говорила ваша маска; при True писатель сбрасывает бит 13 и в /P, и в /Perms. Остальные зарезервированные биты остаются вашей заботой — отсюда стартовая точка $FFFFF0C0. Пример выбирает erR6, потому что R6 — ревизия AES-256, стандартизованная ISO 32000-2; запись по умолчанию несёт erR5

/Perms — зашифрованная копия /P в файлах AES-256

В ревизиях 2–4 слово разрешений приварено к ключу: поменяйте один бит /P — и вход MD5 меняется, выведенный ключ неверен, и не расшифровывается ничего. Файлы AES-256 эту связку рвут. Algorithm 2.A из ISO 32000-2 §7.6.4.3.3 разворачивает ключ шифрования файла из /UE или /OE, и P в том выводе не участвует, так что сам по себе открытый /P можно править свободно. Пробелину закрывает /Perms. Algorithm 10 (§7.6.4.4.9) заполняет блок в 16 байт и шифрует его файловым ключом AES-256 в режиме ECB:

  • байты 0–3: слово разрешений, младший байт вперёд, расширенное до 64 бит байтами 4–7, все — $FF
  • байт 8: ASCII T или F, в соответствии с /EncryptMetadata
  • байты 9–11: ASCII-маркер adb
  • байты 12–15: случайные данные, которые читатели игнорируют

Шаг (f) Algorithm 2.A расшифровывает блок, проверяет adb и требует совпадения байтов 0–3 с P — shall, не should. Algorithm 13 (§7.6.4.4.12) повторяет проверки маркера и разрешений с более мягким should и добавляет ещё одну: байт 8 против EncryptMetadata. PDFium Component читает это строгим способом везде, где собственный код открывает слой шифрования: расшифровка AESV4, валидация PDF MAC и инкрементные сохранения в зашифрованные файлы. После того как пользовательский или владелецский пароль открывает файловый ключ, этот код сравнивает все четыре байта разрешений с нормализованным /P, проверяет заполнитель $FF, байт T/F и маркер и валит загрузку с «PDF encryption permissions validation failed» при любом расхождении, ведь несовпадение значит, что кто-то правил словарь после шифрования, и ни одной копии нельзя верить на слово

Блок /Perms AES-256 в PDFium Component: слово разрешений /P младшим байтом вперёд, заполнитель $FF, байт T или F от /EncryptMetadata и маркер adb — шифруется Algorithm 10 и проверяется по Algorithms 2.A и 13
Поскольку файловый ключ AES-256 больше не зависит от /P, расшифрованные байты /Perms и есть то, что доказывает: открытое слово разрешений не редактировалось после шифрования

Сравнение с EncryptMetadata заслуживает аккуратности в любом читателе, который вы напишете. Трюк вроде Pos('/EncryptMetadata false', Dict) > 0 пропустит /EncryptMetadata false с двумя пробелами, перенос строки между ключом и значением или комментарий между ними — всё это легальный синтаксис PDF. Тогда читатель примет дефолтное true, найдёт F в совершенно валидном /Perms и отвергнет файл. Валидный файл — в отказ. Разбирайте ключ и значение как токены, а небулево значение считайте признаком битого файла, а не поводом подставить дефолт

ISO/TS 32004 даёт биту 13 работу: когда он сброшен, документ несёт PDF MAC, который читатели обязаны проверять. Токен MAC сидит в словаре /AuthCode трейлера, который не шифруется, поэтому проверка, ищущая только /AuthCode, заключит, что файл с удалённым словарём никогда не был под защитой целостности. Удаление заодно /KDFSalt требование не прячет: сброшенный бит живёт в /P, а /P аутентифицируется /Perms. Поэтому расшифровка AESV4 в PDFium Component считает сброшенный бит 13 или остаточный /KDFSalt без /AuthCode сорванным MAC и отказывает, а при выключенной защите целостности писатель ставит бит 13, чтобы его вывод не возвещал MAC, которого нет. Иерархия ключей и диапазон байтов за этим токеном относятся к шифрованию AES-GCM и MAC ISO/TS 32004 в PDF 2.0, а итерированный хеш пароля, открывающий файловый ключ до запуска всего этого, разобран в хеше пароля R6 и числе его раундов

Permissions или UserPermissions: что читать вашему UI?

Читайте Permissions, чтобы решить, что может текущая сессия, и UserPermissions, чтобы отчитать, что файл разрешает обычному получателю. TPdf.Permissions оборачивает PDFium-овский FPDF_GetDocPermissions и описывает доступ, выданный этому открытому документу, — он зависит от пароля, которым файл открыли. TPdf.UserPermissions оборачивает FPDF_GetDocUserPermissions и продолжает отчитывать выдачу user access из /P, каким бы паролем файл ни открыли, поэтому оба могут расходиться, когда файл открыл владелецский пароль. Оба — LongWord; GrantedPermissions и UserGrantedPermissions декодируют их в наборы TPdfPermissions, а HasPermission и UserHasPermission проверяют отдельный член. SecurityHandlerRevision говорит, есть ли у файла вообще security handler. Процедура ниже печатает оба целых вида сырого слова рядом с декодированным набором — быстрый способ понять, почему файл «не печатается»

uses
  System.SysUtils, System.TypInfo, PDFium;

procedure DumpPermissions(const FileName, Password: string);
var
  Pdf: TPdf;
  Mask: LongWord;
  Granted: TPdfPermissions;
  P: TPdfPermission;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.Password := Password;   // выставить до Active
    Pdf.Active := True;
    if not Pdf.Active then
      raise Exception.CreateFmt('Could not open %s', [FileName]);

    if Pdf.SecurityHandlerRevision <= 0 then
    begin
      Writeln('Not encrypted: no /P to decode');
      Exit;
    end;

    Mask := Pdf.UserPermissions;
    Granted := Pdf.UserGrantedPermissions;
    Writeln(Format('R%d  /P as Integer %d, as Cardinal %u ($%.8x)',
      [Pdf.SecurityHandlerRevision, Integer(Mask), Mask, Mask]));

    for P := Low(TPdfPermission) to High(TPdfPermission) do
      Writeln(Format('  %-22s %s',
        [GetEnumName(TypeInfo(TPdfPermission), Ord(P)),
         BoolToStr(P in Granted, True)]));

    if not Pdf.HasPermission(permCopy) then
      Writeln('  this session may not copy text');
  finally
    Pdf.Free;
  end;
end;

// Ожидание для файла, записанного ранее:
//   R6  /P as Integer -1084, as Cardinal 4294966212 ($FFFFFBC4)
//   permPrint True, permModify False, permCopy False, ...

Принуждение — забота приложения-хозяина

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

В файле AES-256 -1084 в открытом тексте ничего не доказывает, пока первые четыре расшифрованных байта /Perms не прочтутся как C4 FB FF FF. Это побайтовое сравнение живёт внутри кода AESV4, PDF MAC и зашифрованных инкрементных сохранений PDFium Component