Вы шифруете отчёт с маской, которая должна была разрешать всё, — $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 | Печать, с качеством, зависящим от бита 12 | permPrint |
| 4 | $00000008 | Изменение содержимого операциями, кроме тех, что у битов 6, 9 и 11 | permModify |
| 5 | $00000010 | Копирование или извлечение текста и графики | permCopy |
| 6 | $00000020 | Добавление или изменение аннотаций, заполнение полей; с битом 4 — создание полей | permAnnotate |
| 7–8 | $000000C0 | Зарезервировано, должно быть 1 | нет |
| 9 | $00000100 | Заполнение существующих полей форм, включая поля подписи, даже если бит 6 сброшен | permFillForms |
| 10 | $00000200 | Извлечение для доступности; в PDF 2.0 не используется, всегда пишется как 1 | permAccessibility |
| 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 -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);
Собираем маску из набора 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» при любом расхождении, ведь несовпадение значит, что кто-то правил словарь после шифрования, и ни одной копии нельзя верить на слово
Сравнение с 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