技術記事

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

証明書暗号化されたPDFは、ファイルが誰のために書かれたかにかかわらず、暗号化辞書に1つの/P値だけを運びます。この記事の後半で組み立てるファイルでは-64です。3つの異なる権限セットを持つ3人の受信者が、その1つの数値を共有します。/Pから「このユーザーは印刷できるか?」に答えるコードは、プレースホルダーを読んでいます。公開鍵セキュリティハンドラーの下では、本物の権限ワードは各受信者の暗号化エンベロープの中を旅し、その受信者の秘密鍵がそれを開けた後で初めて姿を見せるからです

その設計こそ、証明書暗号化が手間の価値がある理由です。四半期の監査パックが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の下の/CFM /AESV3を持つ/DefaultCryptFilterエントリー、そしてトップレベル辞書ではなくそのcrypt filterの中に置かれた/Recipients配列を意味します

各エンベロープは24バイトを暗号化します。20バイトのシードに、その受信者の32ビット権限ワードが続きます。ロード時、リーダーは1つのエンベロープを開封し、シードを復元し、シードを/Recipients順のすべてのエンベロープとともにハッシュし(AES-256ではSHA-256、旧暗号ではSHA-1)、ファイルキーを再構築します。レビュアーBはレビュアーAに何が許されるかを知ることはありませんが、それでもBのリーダーはAのエンベロープバイトをハッシュするため、配列全体がバイト単位で生き残らなければなりません。このモデルと普通のパスワードの間でまだ選んでいるなら、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、DERエンコードされた証明書ごとに1回のAddPubKeyRecipientCertificate、すべてBeginDocの前に。ヘルパーはRSAES-OAEPエンベロープをプロセス内で組み立てます。最後の2つのパラメーターが、THPDFRSAOAEPHash(rohSHA256、rohSHA384、rohSHA512)からOAEPダイジェストとMGF1ダイジェストを選び、どちらも既定はrohSHA256で、エンベロープコンテンツは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つの細部が重みを持ちます。シードはどのキー型でも、AES-256を含めて20バイトで、EnablePubKeyEncryptionはそれ以外の長さで上げられます。キー型の既定はaes128なのに、両方の証明書ヘルパーはaes256を主張するため、2つ目の引数を落とすと、最初のAddPubKeyRecipientCertificate呼び出しが"certificate envelopes require aes256"を上げます。レガシー暗号(k40、k128、aes128)は、他のツール群が組み立てた完全なDERエンベロープを受け取るAddPubKeyRecipient経由で引き続き利用できます

3つ目の細部はバージョンです。公開鍵ハンドラーの下のAES-256はPDF 2.0の機能で、HotPDFはドキュメントバージョンを自力で2.0へ引き上げます。低いバージョンでStrictVersionLockが設定されていると、その引き上げは許されません。EnablePubKeyEncryptionは何も有効化せずに返り、エラーは1行後、受信者ヘルパーが"call EnablePubKeyEncryption first"と報告したときに表面化します。このメッセージは人を、呼び出し順の間違い探しへ送ります。代わりにバージョンロックを確認してください。インクリメンタルアップデート中の暗号化変更は、EInvalidOpExceptionで丸ごと拒否されます

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

楕円曲線証明書はAddPubKeyAgreementRecipientWithSecretを通ります。これはCMSのkey-agreement受信者(RFC 5753のKeyAgreeRecipientInfo、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がoriginator公開鍵を導出するのはX25519とX448だけ(HPDFX25519PublicFromScalar、HPDFX448PublicFromScalar)なので、P-256、P-384、P-521ではエフェメラルペアはあなたのツール群から来ます。ちょうど体のサイズ(32、48、66バイト)のビッグエンディアンスカラーと、対応する非圧縮の0x04||X||Y点(65、97、133バイト)をOriginatorPublicKeyとして渡し、NISTスキームのために予約されたパラメーターOwnPublicPointに同じ点を渡してください。間違った長さは即座に上げられます。証明書から取られた受信者の点は、乗算が走る前に曲線方程式に対して検査されます

どんなライブラリも検査できないのは、あなたのoriginator公開鍵があなたのスカラーに属しているかどうかです。食い違った半分同士でも、整形されたエンベロープはできあがります。ライターはスカラー×受信者の点から鍵を導出し、受信者は自分の秘密鍵×間違ったoriginator点から導出し、受信者側のRFC 3394完全性チェックが最初に気づく頃には、ファイルはもう出荷済みです。同じ曲線のテスト鍵に対する往復ロードをビルドに入れてください。成功したEndDocとそれらしいファイルサイズは、ここでは何も証明しません

このペアリングステップこそ、実務で曲線選択が重要である理由です。証明書の発行を自分で管理できるなら、X25519はDelphiから正しく扱いやすい曲線です。32バイトのランダムスカラー1つ、ヘルパー呼び出し1回、手で同期を保つ点がありません。NIST曲線が居場所を得るのは、受信者の証明書がP-256かP-384しか発行しないPKIから来るときで、その場合は往復ロードは任意ではなくなります

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はエンベロープを追加した順に保持し、変更せずに書くため、順序は自由に選べます。その後、配列に何が起きるかがリスキーな部分です。後処理ツール、署名パイプライン、自家製のエンベロープビルダーは壊れる場所であり、失敗は1つの形を共有します。2つの側がわずかに違うバイトをハッシュする、という形です

  • DelphiのuntypedなTListは生ポインタを保存します。List.Add(Pointer(Bytes))でエンベロープを集めると、参照カウントはローカル変数に残るため、その変数への次のSetLengthがバッファを解放または再利用し、すべてのスロットが最後のエンベロープにエイリアスし得ます。TList<TBytes>か素のTArray<TBytes>なら参照はカウントされ続けます
  • インプレースで働くDERパーサーは、パースしたバッファを変更します。各エンベロープの、どんな開封の試みも触れる前の、清潔なコピーをハッシュしてください
  • UnicodeのTStringListはバイナリDERの場所ではありません。$80以上のバイトは、入りの道でコードページによって再エンコードされます。エンベロープはTBytesに保持するか、先にhexエンコードしてください
  • 暗号文はhex文字列に属します。リテラル文字列の中では、CR、LF、CRLFはいずれも単一のLFとして読み戻され(ISO 32000-1 §7.3.4.2)、たまたま$0D $0Aを含むエンベロープは1バイト失います。HotPDFはすべての/Recipientsエントリーをhex文字列として書き、string encryptionの対象外に置きます。リーダーは、どんな鍵を保持するより前にエンベロープを必要とするからです
  • DERのBIT STRINGの最初のコンテンツバイトは未使用ビット数を数え、byte整列の鍵では$00でなければなりません。このバイトを落として0x04||X||Y点をコンテンツに直行させる手書きDERは、鍵全体を1バイトずらし、厳格なパーサーはoriginator鍵を丸ごと拒否します
  • 動くはずの鍵がそれでも復号できないときは、この順で層ごとに比較してください。ファイルキー、暗号文の先頭のIV、オブジェクトごとの鍵、平文。故障は、食い違った最初の層の直後に座っています

秘密鍵で証明書暗号化されたPDFを読み込む

秘密鍵はLoadFromFileが走る前に登録されていなければなりません。HotPDFは構造パスの間にファイルキーを復元するからです。HPDFParsePFXでパースされたRSAアイデンティティはPubSecKeyMaterialへ入り、追加のRSA鍵はAddPubSecKeyMaterialへ入ります。こちらは、法番号と秘密指数を持たない素材を拒否します。どちらの鍵スロットもRSAの法番号と秘密指数でマッチングされるため、EC鍵は、PFX由来のものも含めて、3つ目の道を通ります。AddPubSecAgreementKeyMaterial(CurveOID, PrivateScalar, OwnPublicPoint)で、HPDFOIDX25519、HPDFOIDX448、HPDFOIDECP256、HPDFOIDECP384、HPDFOIDECP521の定数のいずれかを使います。NIST曲線はそこに、受信者自身の非圧縮公開点を要求し、Montgomery曲線はそれを無視します

PFXファイルとして届くECアイデンティティは、同じ呼び出しに収まります。HPDFParsePFXはそれをKeyAlgorithm = pfxkaECDSA(X25519とX448のアイデンティティも含む)として報告し、CurveOID、PrivateKeyの秘密スカラー、PublicKeyの証明書点を埋めます。これらは3つのパラメーターへ直に写像されます

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

procedure OpenAuditPack(const LegalScalar: TBytes);
var
  Reader: THotPDF;
  EcKey: THPDFPFXKeyMaterial;
begin
  Reader := THotPDF.Create(nil);
  try
    Reader.AutoLaunch := False;
    // RSAアイデンティティがプライマリーキーになる
    Reader.PubSecKeyMaterial :=
      HPDFParsePFX(TFile.ReadAllBytes('reviewer-a.pfx'), 'pfx-password');
    // PFXから来るECアイデンティティ:曲線、スカラー、点はagreementリストへ
    EcKey := HPDFParsePFX(TFile.ReadAllBytes('reviewer-c-p256.pfx'), 'pfx-password');
    if EcKey.KeyAlgorithm = pfxkaECDSA then
      Reader.AddPubSecAgreementKeyMaterial(EcKey.CurveOID,
        EcKey.PrivateKey, EcKey.PublicKey);
    // 法務チームが保持する生のX25519スカラー。点は不要
    Reader.AddPubSecAgreementKeyMaterial(HPDFOIDX25519, LegalScalar, nil);
    Reader.LoadFromFile('audit-pack.pdf', '');
    Writeln('Pages: ', Reader.GetLoadedPageCount);
  finally
    Reader.Free;
  end;
end;

放置すると、HotPDFはすべてのエンベロープをすべての登録済み鍵に対して試します。プライマリーのRSA鍵、次に追加のRSA鍵それぞれ、次にEC素材。PubSecRecipientQueryはそれを単一のエンベロープへ絞ります。コールバックはエンベロープ数を受け取り、配列の中のゼロ始まりのインデックスを返さなければなりません。それ以外の値は、-1を含めて、ロード中に上げられます。すべてのエンベロープを試すなら、プロパティを未割り当てのままにしてください。Delphiではこの型はfunctionへの参照なので匿名メソッドが使え、FPCでは素の関数ポインタで、Context引数はPubSecRecipientQueryContextから来ます

1つの振る舞いには、それ専用のテストの価値があります。登録済みのどの鍵もどのエンベロープも開封できないとき、鍵復元はファイルキーなしで終わり、その理由では上げません。文書が持っているとわかっているコンテンツ、テキスト文字列やメタデータ値にアサートしてください。正常に返ったLoadFromFile呼び出しが示すのは、ファイル構造がパースされたことだけです

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

対象ビューアーで何を確認すべきか?

HotPDFのライターとリーダーはバイト単位で一致し、エンベロープは上で引用したCMS構造に従います。サードパーティのビューアーは別の問題です。RSA-OAEP鍵転送への対応、ましてやX25519やX448の受信者への対応は、リーダーごとに、同じリーダーのバージョン間でも違います。文書が特定のビューアーで開けなければならないなら、同じキー型のテスト証明書用にテストファイルを暗号化し、方式に確定する前にそこで開いてください。ビューアーが自分の管理外なら、対応リストが方式を選び、それは先に述べたX25519の便利さの議論より上位です

エンベロープの権限は、パスワード暗号化の下とまったく同じく、準拠ソフトウェアが尊重するポリシーのままです。シードの品質はあなたの担当です。その仕事のためにAESGenerateRandomBytesが存在し、HotPDFはファイルキーの導出後、自分のシードのコピーを消し去ります。文字列、ストリーム、添付に別のcrypt filterが要るなら、StmF、StrF、EFFのcrypt filterポリシーガイドが割り当て方を示します。公開鍵ハンドラーはDefaultCryptFilterかIdentityを受け付けます

RSAと楕円曲線証明書のための受信者ごとのエンベロープと、帰りの鍵ロードは、パスワード暗号化と電子署名の隣に、DelphiとC++Builder向けのHotPDF PDFコンポーネントの一部です