技術記事

DelphiのPDF証明書暗号化:RSA-OAEPとECDH

HotPDFは、ISO 32000の公開鍵セキュリティハンドラーを通して、特定の証明書の保持者向けにPDFを暗号化します。EnablePubKeyEncryptionが20バイトのランダムシードを受け取り、各受信者は自分のCMSエンベロープを得ます。RSA鍵なら(RSA-OAEP鍵トランスポート)、AddPubKeyRecipientCertificateが作り、楕円曲線鍵なら(P-256、P-384、P-521、X25519、X448上のECDH)、AddPubKeyAgreementRecipientWithSecretが作ります。パスワードを共有する人はいません。一致する秘密鍵を握っている人がファイルを開けます

ユースケースは常に、同じ話のバリエーションです。四半期監査パックが外部のレビュアー3人へ行く。法務は全員に読ませたいが、印刷できるのは1人だけ。そして誰も、添付ファイルの隣のメールスレッドにパスワードを座らせたくありません。パスワード暗号化にはこれは表現できません。証明書暗号化なら表現できます。各受信者はすでに握っている鍵で文書を解錠し、各受信者は自分のエンベロープの中に異なる権限セットを運べるからです

証明書ベースのPDF暗号化はパスワードとどう違うか

公開鍵暗号化PDFのファイルキーは、人がタイプする何かからではなく、ランダムシード+各受信者エンベロープの正確なバイト列から導出されます。ハンドラーはISO 32000-1 §7.6.4(ISO 32000-2では§7.6.5)に記述され、エンベロープはRFC 5652で定義されるCMS EnvelopedData構造です。HotPDFは/Filter /Adobe.PubSecに/SubFilter /adbe.pkcs7.s5を書きます。AES-256の場合、それは/V 5と、/CFの下の/DefaultCryptFilterエントリーに/CFM /AESV3を意味し、/Recipients配列はそのcrypt filterの中に住みます。各エンベロープは24バイトを暗号化します。20バイトのシードに続いて、その受信者の32ビット権限ワードです。暗号化辞書の/P値はプレースホルダーにすぎません。本物の権限は各エンベロープの中を旅するからです。読み込み時にリーダーは1つのエンベロープを開封し、シードを復元し、/Recipients順で(AES-256ならSHA-256、旧暗号ならSHA-1)、シードとすべてのエンベロープをハッシュしてファイルキーを再構築します。このモデルと普通のパスワードの間でまだ迷っているなら、AES-256パスワード暗号化と権限フラグのガイドがトレードオフのもう一方を扱います

HotPDFの公開鍵暗号化の図。EnablePubKeyEncryptionが20バイトのシードを確定し、各CMS EnvelopedDataエンベロープは/Filter /Adobe.PubSec、/SubFilter /adbe.pkcs7.s5、/CFM /AESV3のもとで、その20バイトと32ビット権限ワード1つを暗号化します。リーダーは1つのエンベロープを開封し、シードを復元し、配列順で全/Recipientsエントリーとハッシュしてファイルキーを再構築します
暗号化辞書の/P値はプレースホルダーにすぎません。本物の権限は各エンベロープの中を旅するからです。そしてダイジェストが走る配列を、下流で並べ替えたり再エンコードしたりしてはいけません

EnablePubKeyEncryptionでRSA受信者を書く

RSAの証明書では、aes256を付けてEnablePubKeyEncryptionを呼び、それからBeginDocの前に、DERエンコードされた証明書ごとにAddPubKeyRecipientCertificateを1回呼びます。ヘルパーはプロセス内でRSAES-OAEPエンベロープを組み立てます。OAEPダイジェストとMGF1ダイジェストにはTHPDFRSAOAEPHash値(rohSHA256、rohSHA384、rohSHA512)を使い、エンベロープの内容はAES-256-CBCで暗号化します

uses
  System.SysUtils, System.IOUtils, HPDFDoc, HPDFCrypt, HPDFRSA;

procedure WriteAuditPack(const OutFile: string);
var
  Pdf: THotPDF;
  Seed: AnsiString;
begin
  SetLength(Seed, 20);                      // AES-256でも、きっかり20バイト
  AESGenerateRandomBytes(@Seed[1], Length(Seed));
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := OutFile;
    Pdf.EnablePubKeyEncryption(Seed, aes256, True);   // 既定のキー型はaes128
    // レビュアーAは印刷可。レビュアーBは読み取りと抽出のみ
    Pdf.AddPubKeyRecipientCertificate(TFile.ReadAllBytes('reviewer-a.cer'),
      [prPrint, prPrint12bit, prExtractContent], rohSHA256, rohSHA256);
    Pdf.AddPubKeyRecipientCertificate(TFile.ReadAllBytes('reviewer-b.cer'),
      [prExtractContent]);
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 11);
    Pdf.CurrentPage.TextOut(72, 720, 0, 'Q3 audit pack');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

このリスティングには荷重を支える細部が3つあります。1つ目、シード長はすべてのキー型で20バイトに固定です。AES-256でもです。他の長さではEnablePubKeyEncryptionが例外を上げます。2つ目、EnablePubKeyEncryptionの既定はaes128で、2つの証明書ヘルパーはキー型がaes256でなければ実行を拒むので、第2引数を忘れると「certificate envelopes require aes256」の例外が返ってきます。旧暗号(k40、k128、aes128)は今も動きますが、別の場所で組み立てたエンベロープをAddPubKeyRecipientに渡すときだけです。3つ目、AES-256の公開鍵暗号化はPDF 2.0の機能なので、HotPDFは文書バージョンを自動的に2.0へ引き上げます。低いバージョンでStrictVersionLockを設定していると、EnablePubKeyEncryptionは何も有効にせず返り、失敗は次の行で「call EnablePubKeyEncryption first」としてだけ現れます。インクリメンタル更新の途中で暗号化を切り替えると、EInvalidOpExceptionが即座に上がります

ECDH受信者の追加:P-256、P-384、P-521、X25519、X448

楕円曲線の証明書では、AddPubKeyAgreementRecipientWithSecretがCMSの鍵合意受信者を書き(KeyAgreeRecipientInfo、RFC 5753のKARI構造で、X25519とX448のプロファイルはRFC 8418)、ECDHの共有秘密をプロセス内で計算します。曲線はTHPDFPubKeyAgreementScheme値で選びます。pkasECDHP256、pkasECDHP384、pkasECDHP521、pkasX25519、pkasX448です。スキームは証明書の鍵と一致していなければならず、そうでなければ呼び出しは「Certificate key does not match the requested agreement scheme」を上げます。内部では、各エンベロープが新鮮なランダム32バイトのUKMを得、stdDH KDFで導出された鍵暗号化キー(P-256とX25519はSHA-256、P-384はSHA-384、P-521とX448はSHA-512)、そしてRFC 3394で定義されるAES-256キーラップを運びます。共有秘密そのものは純Pascalの曲線コードから来ており、プラットフォームの暗号プロバイダーは関与しません。その層がどう作られ検証されたかは、純PascalのNIST曲線演算の記事が説明します。Montgomery曲線では、エフェメラル鍵ペア全体をローカルで生成できます:

uses
  System.SysUtils, System.IOUtils, HPDFDoc, HPDFCrypt, HPDFPubSec,
  HPDFKeyAgreement;

procedure AddLegalRecipient(Pdf: THotPDF);
var
  Scalar, OriginatorPublic: TBytes;
begin
  // エンベロープごとの新鮮なエフェメラルスカラー。クランプはラダーの内側
  SetLength(Scalar, 32);
  AESGenerateRandomBytes(@Scalar[0], Length(Scalar));
  try
    OriginatorPublic := HPDFX25519PublicFromScalar(Scalar);
    Pdf.AddPubKeyAgreementRecipientWithSecret(
      TFile.ReadAllBytes('legal-x25519.cer'),
      [prPrint, prExtractContent], pkasX25519,
      OriginatorPublic, Scalar,
      []);   // OwnPublicPoint:NIST曲線でのみ意味を持つ
  finally
    HPDFSecureClearBytes(Scalar);
  end;
end;

NIST曲線は呼び出し側にもっと要求します。HotPDFが公開鍵のヘルパーを同梱するのはX25519とX448(HPDFX25519PublicFromScalar、HPDFX448PublicFromScalar)だけです。だからP-256、P-384、P-521では、エフェメラル鍵ペアを自分のツールで生成し、フィールドサイズちょうどのビッグエンディアンスカラー(32、48、66バイト)と、対応する非圧縮の0x04||X||Y点をOriginatorPublicKeyとして渡します。HotPDFは受信者の点を曲線方程式に対して検証しますが、originatorの公開鍵が実際にあなたのスカラーに属するかは確認できません。食い違う2つ半分でも、見た目は完全に整った、しかし誰も開けないエンベロープができます。だからラウンドトリップの読み込みは、ファイルサイズの確認だけではなく、テストスイートに入れるべきです

HotPDFのECDH合意の図。AddPubKeyAgreementRecipientWithSecretは純Pascalの曲線コードで共有秘密を導出し、新鮮な32バイトUKMをstdDH KDFに通して混ぜます。P-256とX25519はSHA-256、P-384はSHA-384、P-521とX448はSHA-512。そのうえでコンテンツキーをRFC 3394のAES-256キーラップで包み、KeyAgreeRecipientInfoエンベロープを組み立てます
pkasECDHP256からpkasX448までのスキーム値は証明書の鍵と一致しなければならず、食い違うスカラーと公開点の半分でも、整形された、しかし誰も開けないエンベロープができあがります

/Recipientsの順序が問題になるのはなぜか

/Recipientsの順序が問題になるのは、ファイルキーがシードと全エンベロープの配列順のダイジェストだからです。ライターとリーダーは、同じバイト列を同じ順序でハッシュしなければなりません。HotPDFはエンベロープを追加した順に保ち、無変更で書くので、受信者は好きな順で追加してかまいません。しかし下流で、その配列を並べ替えたり再エンコードしたり「掃除」したりしてはいけません。この領域の実際のバグの大半は、そのテーマのバリエーション、つまり2つの側がわずかに違うバイト列をハッシュしていました:

  • TListにAddで動的配列を格納すると、生ポインターだけが保持され、参照カウントはローカル変数に残ります。次のSetLengthがバッファーを解放し、再利用するかもしれません。その結果、すべてのスロットが最後のエンベロープのエイリアスになり、複数受信者のファイルは間違ったキーを導出しました。修正は、List.Add(Pointer(System.Copy(Bytes)))で所有するコピーを格納することです
  • エンベロープの開封はその場でDERを解析し、鍵復元のパスはもともと、同じ生きている配列をハッシュしていました。いまはリーダーが、開封が触れる前に、すべてのエンベロープの無傷のコピーをスナップショットし、ダイジェストはそのスナップショットの上を走ります
  • UnicodeのTStringListを通したバイナリDERは、$80以上のバイトをコードページで再エンコードされます。だからHotPDFはエンベロープを内部的にhexテキストで格納します
  • 暗号化された文字列とバイナリ文字列はhex文字列として書かなければなりません。リテラル文字列はend-of-line正規化の対象で、CR、LF、CRLFがすべて単一のLFになります(ISO 32000-1 §7.3.4.2)。これが暗号文を静かに書き換えます。HotPDFはすべての/Recipientsエントリーをhex文字列として出し、文字列暗号化から除外します。どのリーダーも、キーを1つも握る前にエンベロープを必要とするからです
  • DER BIT STRINGの最初のバイトは未使用ビット数を数え、バイト境界の鍵ではゼロでなければなりません。SetLengthの後に未初期化のままだと、スタックにあった何かが書き込まれ、厳格な開封器はoriginatorの鍵を拒否しました。ファイルが、まさにそのために書かれた鍵で開けないことが、たまに起こり得たのです
  • 同じ鍵でも復号できないときは、層ごとに比較してください。ファイルキー、次に暗号文のプレフィックス(IV)、次にオブジェクトキー、次に平文です。バグは、最初に食い違う層の直後に住んでいます

証明書暗号化PDFを秘密鍵で開くには

証明書暗号化PDFを開くには、LoadFromFileを呼ぶ前に秘密鍵の素材を登録します。HotPDFは構造パスの間にファイルキーを復元するからです。HPDFParsePFXでパースしたRSAまたはEC鍵をPubSecKeyMaterialへ代入し、追加のRSA鍵はAddPubSecKeyMaterialで加え、生のECDHスカラーはAddPubSecAgreementKeyMaterial(CurveOID, PrivateScalar, OwnPublicPoint)で登録します。定数はHPDFOIDX25519、HPDFOIDX448、HPDFOIDECP256、HPDFOIDECP384、HPDFOIDECP521です。NIST曲線は受信者自身の非圧縮公開点を必要とし、Montgomery曲線はそれを無視します

uses
  System.SysUtils, System.IOUtils, HPDFDoc, HPDFPFX, HPDFKeyAgreement;

procedure OpenAuditPack(const LegalScalar: TBytes);
var
  Reader: THotPDF;
begin
  Reader := THotPDF.Create(nil);
  try
    Reader.AutoLaunch := False;
    Reader.PubSecKeyMaterial :=
      HPDFParsePFX(TFile.ReadAllBytes('reviewer-a.pfx'), 'pfx-password');
    Reader.AddPubSecAgreementKeyMaterial(HPDFOIDX25519, LegalScalar, nil);
    // 省略可能:全部試す代わりに、エンベロープを直接選ぶ
    Reader.PubSecRecipientQuery :=
      function(Context: Pointer; RecipientCount: Integer): Integer
      begin
        Result := -1;   // -1 = すべてのエンベロープを順に試す
      end;
    Reader.LoadFromFile('audit-pack.pdf', '');
    Writeln('Pages: ', Reader.GetLoadedPageCount);
  finally
    Reader.Free;
  end;
end;

コールバックなしでは、HotPDFはすべてのエンベロープを、登録されたすべての鍵に対して試します。プライマリーキーを最初に、次に追加のRSA鍵それぞれ、次にECの素材です。PubSecRecipientQueryはエンベロープ数を受け取り、0ベースのインデックスか-1を返します。配列の外のインデックスは、クランプされる代わりに例外を上げます。注意として、AddPubSecKeyMaterialはRSAの素材だけを受け付けます(法と秘密指数を強く要求します)。だからEC鍵はPubSecKeyMaterialかAddPubSecAgreementKeyMaterialに入れます。どの鍵もどのエンベロープも開封できなければ、復元ステップは例外を上げる代わりに、ファイルキーなしで返ります。だから読み込み呼び出しが返ったことを信用するのではなく、期待するコンテンツが実際に復号されたことを確認してください

HotPDFの秘密鍵読み込みの図。PubSecKeyMaterialはHPDFParsePFXから来るプライマリーのRSAまたはEC鍵を運び、AddPubSecKeyMaterialはRSA鍵だけを追加し、AddPubSecAgreementKeyMaterialはHPDFOIDX25519からHPDFOIDP521までの曲線OIDのもとで生のECDHスカラーを登録します。LoadFromFileでプロバイダーは、プライマリーキー、次に追加のRSA鍵それぞれ、次にEC素材を、すべてのエンベロープに対して試します
どの鍵もどのエンベロープも開封できないとき、復元ステップは例外を上げる代わりにファイルキーなしで返ります。だからコンテンツが実際に復号されたことを確認するか、PubSecRecipientQueryでエンベロープを固定してください

HotPDFが保証しないこと

HotPDFが保証するのは、自分のライターとリーダーがバイト単位で一致すること、そして上に挙げたCMS構造に従うエンベロープを組み立てることです。すべてのPDFビューアーがすべての組み合わせを開けることは保証しません。RSA-OAEP鍵トランスポートとX25519、X448受信者への対応は、リーダーとバージョンによって差があり、それらの組み合わせの互換性結果は公表していません。文書が特定のビューアーで開けなければならないなら、同じキー型のテスト証明書でテストファイルを暗号化し、スキームを決める前にそこで開いてください。エンベロープの中を運ばれる権限は、適合するソフトウェアが尊重するポリシーのままです。パスワード暗号化のときとまったく同じです。シードの品質もあなたの責任です。その仕事のためにAESGenerateRandomBytesがあり、HotPDFはファイルキーの導出後、シードのコピーをワイプします。さらに、文字列やストリームや添付ファイルに別のcrypt filterを使いたいなら、StmF、StrF、EFFのcrypt filterポリシーガイドが、公開鍵ハンドラーが受け付けるフィルター名を示します

証明書暗号化、RSA-OAEPとECDHの受信者エンベロープ、そして秘密鍵の読み込みは、パスワード暗号化、電子署名、そしてISO 32000ツールセットの残りとともに、DelphiとC++Builder向けのHotPDF Delphi PDF componentに同梱されています