技術記事

PDF の権限フラグ:/P の整数と /Perms の複製

すべて許可するつもりでマスク $FFFFFFF0 を使ってレポートを暗号化したら、Acrobat のセキュリティタブは印刷と文書の変更を「許可しない」と一覧します。/P はビット 1 から番号を振る符号なし 32 ビットのフラグワードで、あのマスクは印刷(ビット 3)と変更(ビット 4)を 0 にします。全許可の値は $FFFFFFFC で、これが PDFium Component の TPdfEncryptOptions の出発点です

/P フラグワードのレイアウト

意味を持つのは 8 位置だけで、残りは予約され、数え方は 1 から始まります。ISO 32000-1 の §7.6.3.2 は P を「符号なし 32 ビット量として解釈されなければならない」と述べ、下位側の 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 を引退させます。リーダーは今はこれを無視し、writer は古いリーダーのために常に 1 を立てます。アクセシビリティの特例はビット 5 へ移り、コンテンツが支援技術へ向かうとき、リーダーはコピーが許可されているものとして振る舞うべきとされます。下の表の全行を 1 つの式が覆います。ビット n のマスクは 1 shl (n - 1)。TPdfPermission も同じ対応でワードに乗ります

ビットマスク意味(revision 3 以降)TPdfPermission
1–2$00000003予約。0 でなければならないnone
3$00000004印刷。品質はビット 12 に依存permPrint
4$00000008ビット 6、9、11 の操作以外による内容の変更permModify
5$00000010テキストとグラフィックのコピーまたは抽出permCopy
6$00000020注釈の追加・変更、フィールド入力。ビット 4 と併せてフィールド作成permAnnotate
7–8$000000C0予約。1 でなければならないnone
9$00000100既存フォームフィールドの入力。署名フィールドを含み、ビット 6 が 0 でも有効permFillForms
10$00000200アクセシビリティ抽出。PDF 2.0 では未使用、常に 1 として書かれるpermAccessibility
11$00000400組み立て:ページの挿入、回転、削除、しおり項目とサムネイルの作成permAssemble
12$00000800忠実で高品質な表現への印刷permPrintHighQuality
13–32$FFFFF000予約。1 でなければならない(ISO/TS 32004 はビット 13 を割り当てる。後述)none

$FFFFFFF0 のマスクは、「下位ビットは予約」と読んで、安全のためとニブルごと 0 にした産物です。予約領域はビット 1〜2、マスク $3 だけでした。$F を 0 にすると、印刷と変更を担う $4 と $8 も道連れになり、フラグを尊重するビューアはどちらも拒みます。鏡写しの間違いは、マスクをゼロから積み上げるやり方です。$4、$10、$800 を OR すると、ビット 7〜8 と 13〜32 が 0 の、小さな正の /P が出来上がります。大半のリーダーは何も言わずにそのファイルを開きます。仕様が予約ビットの無視を命じているからです。それでもこの値は revision 3 以降には不適合で、厳密なチェッカーは文句を言う権利があります。3 つ目の変種はビットを 0 から数えるコードに現れます。「ビット 3」が $8 になり、プログラムは印刷のつもりで変更を許してしまう。どれも保存時にエラーを出しません。権限はただ、作者の意図と違う形で出てくるだけです

Delphi の PDFium Component における PDF /P 権限フラグワードのレイアウト。予約ビット 1-2、7-8、13-32、permPrint や permModify といった TPdfPermission の各ビット、マスク $FFFFFFFC と $FFFFFFF0
$FFFFFFF0 で下位ニブルを丸ごと 0 にすると印刷と変更も消えます。マスクをゼロから積み上げると、revision 3 以降の予約ビットが 0 のまま残ります

/P -4 と /P 4294967292 は同じ値か

同じです。同じ 32 ビット、2 つの綴り。PDF の整数オブジェクトは符号付きで、Table 22 の直上の注は、予約された上位ビットが 1 であるため整数は負の数として書かれると指摘します。-4 は revision 3 以降の慣例的な全許可値です。値を符号なし変数に保持する作成者は代わりに 4294967292 と書き、実ファイルでは両方の綴りが現れます。どちらの読みが正しいかは、仕様自身の鍵導出が決めています。ISO 32000-1 §7.6.3.3 の Algorithm 2 のステップ (d) が P を 32 ビットの符号なし 2 進数に変換し、4 バイトを下位バイトから先に MD5 へ渡します。だから revision 2〜4 では -3904 と 4294963392 が同じファイルキーを作ります。/P を符号付き 32 ビットの Integer に格納するパーサは、符号なしの綴りをオーバーフローとして拒みます。変数を Cardinal に替えると今度は負の綴りが失敗し、トークンテキストを保存値と比較するコードは、1 つの文書を 2 つの文書として扱い始めます。トークンは Int64 に読み、Low(Integer) から High(Cardinal) までを受け入れ、比較の前に 32 ビットへマスクしましょう。PDFium Component の復号コードは revision 2〜7 の /P をこの方法で読み、writer は仕様の注が示す符号付きの形を出力します。スニペットの予約ビット報告がビット 14 で止まるのは、ビット 13 が 0 であることが 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 を要求するために 0 にする
  if (Mask and $FFFFE000) <> $FFFFE000 then
    Result := Result + 'bits 14-32 not all set; ';
end;

// 期待結果:両呼び出しとも $FFFFFFFC を返し、予約ビット報告は空
//   TryNormalizePermissions('-4', M1);
//   TryNormalizePermissions('4294967292', M2);
PDFium Component と同じ方法で Delphi で PDF の /P 値を正規化する様子。/P -4 と /P 4294967292 を Int64 に読み、Low(Integer) から High(Cardinal) まで範囲検査し、Cardinal の $FFFFFFFC にマスクする
符号付き Integer と Cardinal のパーサは、どちらも /P の合法な綴りを 1 つずつ拒みます。Int64 で読んでから 32 ビットにマスクすれば、両綴りは 1 つの値に写ります

TPdfPermissions 集合からマスクを組み立てる

まずすべての予約ビットを要求値に置き、許可するものだけを OR します。忘れられた許可は、どう転んでも拒否になる形です。FPdfEncrypt ユニットの TPdfEncryptOptions.Default はすでに $FFFFFFFC を保持しています。全部許可です。手早く制限をかけるなら、ここから 1〜2 ビットを落とすので十分です。受け手に何を許すかを列挙するポリシーは、集合として書くほうが読めます。下の定数配列は、TPdf がワードを解読するのに使うビット位置に並んでいます。ビット 10 は、呼び出し側が何を渡そうと強制オンにしましょう。ISO 32000-2 は writer に常に設定するよう命じ、PDF 2.0 のリーダーはどのみち無視し、0 のままにした場合の唯一の実害は、古いソフトでスクリーンリーダーが塞がれることです

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 と全許可ビットは 0
  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 を運び、フラグを尊重するビューアは、内容編集、コピー、コメント、ページ組み立てをグレーアウトしながら、全品質の印刷とフォーム入力を提供します。ビット 13 は writer が代わりに管理する位置です。EnableIntegrityProtection が False のままだと、SaveAsEncrypted はマスクが何と言おうとビット 13 を立てます。True にすると、writer は /P と /Perms の両方でビット 13 を 0 にします。他の予約ビットはあなたの責任のまま——だから出発点は $FFFFF0C0 です。例が erR6 を選ぶのは、R6 が ISO 32000-2 が標準化する AES-256 の revision だからです。既定のレコードは erR5 を運びます

AES-256 ファイルにおける /P の暗号化複製、/Perms

revision 2〜4 では、権限ワードは鍵に溶接されています。/P の 1 ビットを変えれば MD5 への入力が変わり、導出鍵は間違いになり、何も復号できません。AES-256 のファイルはこの連結を断ちます。ISO 32000-2 §7.6.4.3.3 の Algorithm 2.A は /UE か /OE からファイル暗号化鍵を取り出し、その導出に P は関与しません。だから平文の /P だけなら自由に編集できてしまいます。/Perms がこの隙間を塞ぎます。Algorithm 10(§7.6.4.4.9)が 16 バイトのブロックを埋め、ファイル鍵で ECB モードの AES-256 により暗号化します:

  • バイト 0〜3:権限ワード。下位バイトが先、バイト 4〜7 が 64 ビットへ延長する。4〜7 はすべて $FF
  • バイト 8:ASCII の T か F。/EncryptMetadata に対応
  • バイト 9〜11:ASCII のマーカー adb
  • バイト 12〜15:リーダーが無視するランダムデータ

Algorithm 2.A のステップ (f) はブロックを復号し、adb を検証し、バイト 0〜3 が P に一致することを要求します。shall です。should ではありません。Algorithm 13(§7.6.4.4.12)はマーカーと権限の検査を柔めの should で繰り返し、1 つ加えます。バイト 8 を EncryptMetadata と比べることです。PDFium Component は、自分のコードが暗号化層を開くすべての場所——AESV4 復号、PDF MAC 検証、暗号化ファイルへの増分保存——でこの厳しい読みをします。user か owner のパスワードがファイル鍵を解錠した後、そのコードは 4 つの権限バイトすべてを正規化済みの /P と比較し、$FF の詰め物、T/F のバイト、マーカーを検査し、どれか 1 つでも違えば読み込みを「PDF encryption permissions validation failed」で失敗させます。不一致は、暗号化の後に誰かが辞書を編集したことを意味し、どちらの複製も額面通りには取れないからです

PDFium Component における AES-256 の /Perms ブロック。下位バイトが先の /P 権限ワード、$FF の詰め物、/EncryptMetadata の T か F のバイト、adb のマーカー。Algorithm 10 で暗号化され、Algorithm 2.A と 13 の下で検証される
AES-256 のファイル鍵はもう /P に依存しないので、暗号化の後に平文の権限ワードが編集されていないことを証明するのは、復号された /Perms のバイトです

EncryptMetadata の比較は、自分の書くどんなリーダーでも丁寧に扱う価値があります。Pos('/EncryptMetadata false', Dict) > 0 のような手抜きは、2 個の空白を伴う /EncryptMetadata false、キーと値の間の改行、間に挟まったコメントを見落とします。どれも合法な PDF 構文です。リーダーは既定の true を仮定し、完全に正当な /Perms の中の F を見つけ、ファイルを拒みます。正当なファイルが拒まれるのです。キーと値はトークンとしてパースし、真偽値でない値は既定に落とすのではなく不正形式として扱いましょう

ISO/TS 32004 はビット 13 に仕事を与えます。0 のとき、文書はリーダーが検査しなければならない PDF MAC を担っています。MAC トークンは trailer の /AuthCode 辞書に座り、これは暗号化されません。だから /AuthCode だけを探す検査は、それが削除されたファイルを「そもそも完全性保護されていなかった」と結論します。/KDFSalt も一緒に消しても要求は隠れません。0 になったビットは /P に住み、/P は /Perms が認証しているからです。そこで PDFium Component の AESV4 復号は、/AuthCode のない状態でのビット 13 の 0、あるいは残骸の /KDFSalt を剥ぎ取られた MAC として扱い、失敗します。完全性保護がオフのときは writer がビット 13 を立てるので、出力が持っていない MAC を名乗ることはありません。トークンの背後にある鍵の階層とバイト範囲はPDF 2.0 の AES-GCM 暗号化と ISO/TS 32004 の MAC に属し、この一連の処理が走る前にファイル鍵を解錠する反復パスワードハッシュはR6 のパスワードハッシュとその回数で扱います

Permissions か UserPermissions か、UI が読むべきはどちらか

現在のセッションが何をできるかの判断には Permissions を読み、ファイルが普通の受け手に何を与えるかの報告には UserPermissions を読みます。TPdf.Permissions は PDFium の FPDF_GetDocPermissions を包み、この開いた文書に与えられたアクセスを記述します。どのパスワードで解錠したかに依存します。TPdf.UserPermissions は FPDF_GetDocUserPermissions を包み、どのパスワードを使ったかにかかわらず /P 由来の user アクセス許可を報告し続けます。owner パスワードで開いたとき、2 つは食い違い得ます。どちらも LongWord です。GrantedPermissions と UserGrantedPermissions がこれらを TPdfPermissions の集合へ解読し、HasPermission と UserHasPermission が 1 つのメンバーを試します。SecurityHandlerRevision は、ファイルがそもそもセキュリティハンドラを持つかを教えます。下のルーチンは、生のワードの整数としての 2 つの見え方を、解読済み集合の隣に表示します。ファイルが「印刷できない」理由を素早く見る方法です

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 は、フラグが何と言おうと、描画し、テキストを抽出し、ページをラスタライズします。エンジンは権限を報告し、ポリシーは呼び出し側に残します。user パスワードを握る者は復号済みの内容を握ります。user パスワードが空なら、それは全員です。機密性は、リーダーが持っていない user パスワードからしか来ません。検査は各コマンドハンドラの中へ(コピー、エクスポート、ページ削除、印刷)。ツールバーボタンを隠すやり方ではなく、です。ショートカット、コンテキストメニュー、自動化はどれもツールバーをバイパスします。Delphi アプリ向けの安全な PDF プレビューペインと同じ階層分けです。たとえば印刷コマンドは、何かをスプールする前に permPrint を試します

AES-256 のファイルでは、/Perms の復号された最初の 4 バイトが C4 FB FF FF と読めるまで、平文の -1084 は何も証明しません。このバイト単位の比較は、PDFium Component の AESV4、PDF MAC、暗号化増分保存のコードの中で走ります