技術記事

PDFlibPasによるDelphiでのSASLprep AES-256 PDFパスワード

非ASCIIパスワードで暗号化されたAES-256 PDFは、それを書き込んだプログラムでは開くが、他のどこでも開かない。原因はほぼ常に準備ステップの欠落だ:ISO 32000-2 §7.6.4.3.3は、パスワードがUTF-8エンコードされハッシュされる前に、stringprepのSASLprepプロファイルで処理されることを要求している。Delphi・C++Builder向けPDFライブラリPDFlibPasは、その準備をEncryptEncryptFileDecryptFileの内部で行う

これはパスワード間違いの話でも、権限ビットの話でもない。ユーザーが発行していないパスワードを入力しているのであれば、暗号化されたPDFパスワードの再試行に関する記事にあるリトライの仕組みが求めているものであり、既存のファイルが実際に何を強制しているのかを解明しようとしているなら、暗号化と権限の監査がその領域をカバーしている。この記事はもっと狭く、もっと奇妙だ:パスワードは正しく、ユーザーは正しく入力しており、それでもファイルはどこか別の場所で開くのを拒否する

なぜ非ASCIIパスワードはあるリーダーでは開き別のリーダーでは開かないのか

2つのプログラムが同じキー入力から異なるバイト列をハッシュ化するからだ。ISO 32000-2 §7.6.4.3.3のリビジョン6鍵導出は、パスワードをUTF-8バイトとして取り、127バイトに切り詰め、ソルトを付加し、強化されたハッシュを実行する。結果は暗号化辞書の/U/Oエントリに対して照合される。この連鎖には曖昧な部分はどこにもない。入力のどこか一箇所でバイトが1つ違うだけで、まったく異なるダイジェストが生成され、検証は失敗し、リーダーが言えることは1つだけだ:パスワードが違う

バイトが分岐するのは、Unicodeが同じに見えるパスワードを入力する方法を複数提供しているからだ。中国語のパスワードは、ある入力方式からは合成済み文字として、別の入力方式からは互換形式として届くかもしれない。ワープロからコピーされたドイツ語やフランス語のパスワードは、ユーザーが普通の空白だと思っている場所にNO-BREAK SPACE(U+00A0)を持っていたり、何もレンダリングされないSOFT HYPHEN(U+00AD)を持っていたりする。SASLprepは、誰かが何かをハッシュする前に、これらすべてを1つの正規形に折りたたむために存在する。それによって、すべての準拠実装が同じ意図から同じ鍵を導出できるようになる

SASLprepは実際にパスワードの何を変えるのか

RFC 4013はSASLprepをRFC 3454のstringprepフレームワークのプロファイルとして定義しており、それは1つの変換ではなく4つの順序立った段階だ。まずマッピング:RFC 3454の表C.1.2(非ASCIIの空白)はU+0020にマップされ、表B.1(一般に何もないものにマップされる文字)は完全に削除される。続いてUnicode NFKCへの正規化が行われ、これが互換文字と結合シーケンスを折りたたむ段階だ。次に禁止出力チェックが表C.2.1からC.9までにある何かを拒否する。最後にRFC 3454セクション6の双方向規則が正規化された文字列に適用される

PDFlibPasはこのプロファイル全体をPDFlibSASLprepユニット内に実装しており、1つのエントリポイントを公開している。PLSASLprepPasswordは生のパスワードを受け取り、準備済みの形をvarパラメータに書き込み、パスワードを拒否しなければならない場合はFalseを返す。この関数はハッピーパスでは意図的に全域関数になっている:ASCIIのみのパスワードはバイト単位で同一のまま戻ってくるので、既存の展開について変わるものは何もない

uses
  PDFlibSASLprep;

var
  Prepared: WideString;
begin
  // RFC 4013: mapping, then NFKC, then prohibited output, then the bidi rule
  PLSASLprepPassword('I' + WideChar($00AD) + 'X', Prepared);  // -> 'IX'   B.1 deletes SOFT HYPHEN
  PLSASLprepPassword('a' + WideChar($00A0) + 'b', Prepared);  // -> 'a b'  C.1.2 maps NBSP to U+0020
  PLSASLprepPassword(WideString(WideChar($00AA)), Prepared);  // -> 'a'    NFKC folds ORDINAL INDICATOR
  PLSASLprepPassword(WideString(WideChar($2168)), Prepared);  // -> 'IX'   NFKC folds ROMAN NUMERAL NINE
  PLSASLprepPassword('user', Prepared);                       // -> 'user' ASCII is never touched
end;

表では解決されないU+200Bの曖昧さ

ある1つのコードポイントが同時に2つのRFC 3454の表に属し、その2つの表が食い違っている。ZERO WIDTH SPACE(U+200B)はC.1.2の範囲U+2000からU+200Bに収まり、そこではU+0020にマップせよというルールになっているが、同時にB.1の範囲U+200BからU+200Dにも収まり、そこでは削除せよというルールになっている。マッピング段階をどちらの順で読むかによって、同じパスワードから異なるバイト列が生まれる:a+U+200B+bは、C.1.2の下ではa bに、B.1の下ではabに準備される。RFC 4013はどちらの表にも名前を挙げているが、どちらが優先するかは述べていないので、これは読み違いではなく仕様における本物の曖昧さだ。PDFlibPasはC.1.2のメンバーシップを先にテストするので、結果としてU+200Bを空白にマップする。これは他の広く展開されているstringprep実装が落ち着いた挙動であり、それに合わせることだけがここで重要なことだ。目標は、顧客がたまたま使っているどんなリーダーとでもバイト単位で一致することだからだ

古いファイルを読む:まず準備済み、次に生の値

この修正自体が互換性の問題を生む。この変更以前に書かれたすべてのAES-256ファイルは生のUTF-8パスワードをハッシュしていたので、リーダーを厳密に準拠させると、顧客が自分自身のアーカイブから締め出されてしまう。PDFlibPasは、読み取り側で2つの候補を順に試すことでこれを解決する。TPDFDocument.SetPasswordは、準備済みの形から始まり生の形にフォールバックする候補リストを構築し、ドキュメントが実際にAES-256であり、かつ2つの形が異なる場合にのみ準備済みのエントリを追加する。ASCIIパスワードの場合、両方の形は同一なのでリストは1エントリだけを保持し、この仕組み全体のコストは1回の文字列比較にすぎない。DecryptFileも、直接のAES-256書き換えパスに沿って同じことを行い、PLDirectDecryptFileAES256を準備済みパスワードで先に呼び出す

var
  Lib: TPDFlib;
  Bytes: AnsiString;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetOrigin(1);
    Lib.DrawText(100, 100, 'saslprep roundtrip');
    // Strength 3 and 4 are the two AES-256 values; both are prepared before hashing
    Lib.Encrypt('ow' + WideChar($00AD) + 'ner', 'pa' + WideChar($00AD) + 'ss', 4,
      Lib.EncodePermissions(1, 0, 0, 0, 0, 0, 0, 1));
    Bytes := Lib.SaveToString;
  finally
    Lib.Free;
  end;

  Lib := TPDFlib.Create;
  try
    // 'pass' is what SASLprep produced and what any conforming reader computes,
    // so the plain ASCII form opens a file created with the soft-hyphen form
    if Lib.LoadFromString(Bytes, 'pass') = 1 then
      Caption := IntToStr(Lib.PageCount);
  finally
    Lib.Free;
  end;
end;

このフォールバックには、真似する価値のあるガードが1つある。DecryptFileにおける2回目の試行は、準備済みの形と生の形が異なり、かつ最初の試行がハードエラーコードを報告しなかった場合にのみ実行される。構造的な失敗は、入力が破損しているか、想定していた暗号化リビジョンではないことを意味しており、壊れたファイルを別のパスワードで再試行しても、敵対的な入力に対するフルパースをもう一度焼くだけだ。その反射的判断の背後にある理由は信頼できないPDFを安全にパースするノートで説明されている。書き込み側にはフォールバックがなく、この非対称性は意図的なものであることにも注意してほしい。読み取りは歴史を許容するが、書き込みはしない:新しいAES-256ファイルは常に準拠したバイト列を得る

どのパスワードが完全に拒否されるのか、そしてエラー604とは何か

SASLprepはパスワードを完全に拒否することがあり、その場合、暗号化は黙って何かに置き換えるのではなく、大きな音を立てて失敗しなければならない。EncryptEncryptFileは、Strengthが3または4のときは常に所有者パスワードとユーザーパスワードの両方を準備し、拒否時には0を返し、LastErrorCodeを604であるPDFLIB_ERROR_PASSWORD_SASLPREPに設定する。2つの入力の系統がこれを引き起こす。禁止出力の表は、制御文字(C.2.1とC.2.2)、私用領域のコードポイント(C.3)、非文字(C.4)、孤立サロゲート(C.5)、U+FFFD(C.6)、表意文字記述文字(C.7)、表示制御およびタグ付けの範囲(C.8とC.9)を拒否する。別途、RFC 3454セクション6の双方向規則は、表D.1のRandALCat文字を含む文字列を、その文字列がそのような文字で始まり終わり、かつ左から右への文字を一切含まない場合を除いて拒否する

var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    // U+0007 is a C.2.1 control character, so preparation refuses the password
    if Lib.Encrypt('owner', 'bad' + WideChar($0007), 4,
         Lib.EncodePermissions(1, 0, 0, 0, 0, 0, 0, 1)) = 0 then
    begin
      if Lib.LastErrorCode = PDFLIB_ERROR_PASSWORD_SASLPREP then  // 604
        ShowMessage('The password contains characters that PDF encryption does not permit.');
    end;
  finally
    Lib.Free;
  end;
end;

サポートデスクを驚かせるのはその双方向規則だ。欧文の数字で終わるアラビア語やヘブライ語のパスワード、あるいは途中に紛れ込んだ1文字のラテン文字を持つパスワードは、入力欄では完全に合理的に見えても仕様によって拒否される。604は汎用の暗号化失敗としてではなく、パスワードの文字に関するメッセージとして表示するべきだ。そうしないと、誰かが鍵導出のバグを探して午後を丸ごと費やすことになる

正直な限界:NFKC、近似的なLCat、そして1つのDelphiの罠

実装のうち2つの部分は近似であり、埋もれさせるのではなく率直に述べるべきだ。NFKC正規化はWindowsのNormalizeString APIによって行われ、Normaliz.dllから動的に読み込まれる。そのライブラリが利用できない場合、マッピング後の文字列は正規化されないまま使われる。つまりマッピングと禁止のステップは実行されるが、互換性の折りたたみは行われない。実際にはこのDLLはVista以降のすべてのWindowsリリースに同梱されてきたので、劣化パスはWindows Vista以前や非Windowsの懸念であって現行の懸念ではないが、NFKCの折りたたみに依存するパスワードはそこで異なるバイト列を生成することになり、それは実際の、ただし稀な乖離だ。双方向チェックは2つ目の近似だ:LCat文字の検出は、RFC 3454の完全な表D.2ではなく一般的な文字範囲を使っており、その誤差の方向がこれを許容可能なものにしている。見逃されたLCat文字は、仕様が拒否したであろうところで双方向規則を通過させる方向にしか作用せず、逆はあり得ず、マッピングや正規化のステップには一切触れないので、受理されたパスワードの準備済みバイト列は変わらない。したがって残存リスクはバイトの乖離ではなくポリシーの乖離だ:より厳密な実装であれば受理を完全に拒否するであろう、異国風の文字体系のパスワードだ。両側が受理するすべてのパスワードは同一にハッシュされる。それこそが相互運用性が実際に依存している特性だ

最後に、一度当たったことがなければ1時間を浪費するDelphi構文の罠がある。関数がプロシージャ型を返す場合、括弧なしで代入してもそれは呼び出しにならない。コンパイラはProc := GetNormalizeProc;GetNormalizeProc自身のアドレスを取るものとして読み、E2009を報告し、呼び出し規約が異なるという不親切な不満を述べる。アクセサーがデフォルトの規約を使っているのに対し、インポートされたAPI型はstdcallだからだ。空の括弧は必須だ

type
  TNormalizeString = function(NormForm: Integer; SrcString: PWideChar; SrcLength: Integer;
    DstString: PWideChar; DstLength: Integer): Integer; stdcall;

function GetNormalizeProc: TNormalizeString;   // loads Normaliz.dll on first use
...
var
  Proc: TNormalizeString;
begin
  // Proc := GetNormalizeProc;   // E2009: reads as @GetNormalizeProc, conventions differ
  Proc := GetNormalizeProc();    // correct: calls the accessor and assigns its result
  if not Assigned(Proc) then
    Exit;                        // no NFKC available, mapped string is used as-is
end;

パスワード準備は、機能一覧には決して載らないのに、暗号化されたドキュメントが別のロケールの顧客との接触に耐えられるかどうかを左右する類の細部の一つだ。ここで説明したEncryptEncryptFileDecryptFileSetPasswordのエントリポイントは、Delphi・C++Builder向けlosLab PDF Developer Library Pascal Editionの一部であり、その製品ページには完全な暗号化リファレンスとエラーコード表の全体が掲載されている