技術記事

DelphiでCryptoAPIを使った自己署名テスト証明書

PDFlibPasのPLCreateSelfSignedCertificate関数は、自己署名のRSA/SHA-256証明書を構築し、あらゆるWindowsマシンにすでにインストールされているWin32 CryptoAPIだけを使って、秘密鍵を含めてそのままパスワード保護されたPFXファイルへエクスポートする。外部ツールも、認証局も、手作業のmakecertやOpenSSLのステップも不要:1回の関数呼び出し、署名テストを駆動するのに十分な1つの証明書である

この関数を持っておく価値があるシナリオは、ほぼ常にCIパイプラインである。署名のスモークテストは、その背後に本物の秘密鍵を持つ本物のPFXを必要とし、それをリポジトリにチェックインすることはそれ自体がセキュリティ上の問題である。なぜならコミットされた秘密鍵は、そのコミットが着地した瞬間から漏洩した秘密鍵だからだ。ビルドスクリプトからmakecert.exeやOpenSSLの呼び出しにシェルアウトすることも機能するが、それではパイプラインが、インストールされ、PATHで見つかり、すべてのビルドエージェントにわたってバージョンの一貫性が保たれなければならないツールに依存することになる。テストを実行するのと同じプロセスの内部で、Windowsがすでに出荷している同じWin32 CryptoAPI呼び出しで証明書を生成することは、その依存関係を完全に取り除く

PLCreateSelfSignedCertificateは実際に何を生成するのか

PLCreateSelfSignedCertificateは、sha256RSAで署名された自己署名RSA証明書とその秘密鍵を保持するパスワード保護されたPFXファイルを生成する。それは5つのパラメータ、SubjectNamePFXFileNamePFXPasswordValidDaysKeyBitsによって駆動され、単純なBooleanの成功フラグを返す。SubjectName'CN=Alice, O=Example'のような完全なX.500文字列を受け付け、=記号を含まない裸の名前は自動的にCN=が接頭辞として付けられる。1未満のValidDaysは365にフォールバックし、1024から16384の範囲外のKeyBitsは2048にフォールバックする。PDFlibPasはこの関数をv3.224.0以来出荷しており、Delphiユニットからだけでなく、DLLとActiveXの両方の表面からも到達可能である。そしてその独自のドキュメントコメントは、それがどこで有用でなくなるかについて率直である:誰かが明示的にそれをインストールしない限り、あらゆる主要なビューアは自己署名証明書を信頼できないとマークする。そのためそれが生成するものは、あるコードパスを演習するための証明書として扱うべきであり、チーム外の誰かに頼るよう求めるべき署名としてではない

var
  Success: Boolean;
begin
  Success := PLCreateSelfSignedCertificate(
    'CN=PDFlibPas CI Test, O=Example Corp',
    'ci-test-signer.pfx',
    'a-strong-throwaway-password',
    365,     // ValidDays
    2048);   // KeyBits
  if not Success then
    raise Exception.Create('Self-signed certificate generation failed');
end;

なぜCryptGenKeyは鍵長をflagsパラメータにエンコードするのか

CryptGenKeyは2つの無関係な設定を単一のdwFlagsパラメータに詰め込む。下位ワードはCRYPT_EXPORTABLEを含む挙動フラグを運び、上位ワードは、RSA鍵交換鍵の場合、要求された鍵長をビット単位で運ぶ。2048を単なる別のフラグであるかのように渡すと、それは代わりに下位ワードに着地し、そこではCryptoAPIが定義するどんな挙動フラグとも一致しない。そのためこの呼び出しは、呼び出し元が求めたと思っていた長さではなく、プロバイダがフォールバックする既定の長さで鍵を生成する。実際の2048ビットRSA鍵を得るには、まずその数値を上位ワードにシフトする必要がある

// Key length lives in the upper 16 bits of the CryptGenKey flags;
// the low word carries behavior flags such as CRYPT_EXPORTABLE.
if not CryptGenKey(hProv, AT_KEYEXCHANGE,
    (Cardinal(KeyBits) shl 16) or CRYPT_EXPORTABLE, hKey) then
  Exit;

CRYPT_EXPORTABLEを忘れるとどうなるのか

その同じflags値からCRYPT_EXPORTABLEを落としてもCryptGenKeyはそれでも成功するが、生成された秘密鍵をCSPレベルでエクスポート不可能とマークする。下流のすべても成功を報告し続ける:CertCreateSelfSignCertificateは有効な証明書コンテキストを返し、PFXExportCertStoreExは、EXPORT_PRIVATE_KEYS付きで呼ばれても、それでも成功し、開けて解析できて完全に普通に見えるPFXファイルを書き込む。それが含んでいないのは秘密鍵である、なぜならCSPがそれを鍵コンテナから出すことを拒否したからだ。そしてPFXExportCertStoreExはその拒否をエクスポート全体を失敗させる理由として決して扱わない

その失敗はずっと後、そして全く別の場所でしか現れない:署名呼び出しがそのPFXを開き、秘密鍵が付属していない証明書を見つけ、3層上流にある欠けたフラグからではなく、破損した、あるいは間違ったPFXから得られるであろうまさにそのエラーを報告する。署名側だけからデバッグしている人は誰でも、実際のバグが完全に別の関数呼び出しにおける、鍵生成時のたった1つの欠けたビットであり、ことによると完全に別のビルドスクリプトにあると気づく前に、間違ったファイルに午後を丸ごと費やしてしまうことがある

なぜProvTypeはCryptAcquireContextWと証明書の間で一致しなければならないのか

ProvTypeが一致しなければならないのは、CertCreateSelfSignCertificateCRYPT_KEY_PROV_INFOレコードを通じて新しい証明書の秘密鍵を解決するからであり、そのレコード内の1つのフィールド、ProvTypeは、鍵コンテナが開かれたときにCryptAcquireContextWに渡されたのと正確に同じCSPタイプ値、すなわちPDFlibPasの実装では数値で24のPROV_RSA_AESを名指ししなければならない。ProvTypeをゼロに、あるいはそのコンテナが実際に属しているもの以外のどんなプロバイダ定数に設定しても、証明書は依然として作成できるが、その記録された秘密鍵へのリンクバックは、もはやそれを保持しているコンテナに解決されなくなり、それは後になって証明書の実際の暗号的な内容とは何の関係もない署名やエクスポートの失敗として表面化する

// The provider type used to open the key container must match the
// provider type recorded in the certificate's key-provider info.
CryptAcquireContextW(hProv, PWideChar(Container), nil,
  PROV_RSA_AES, CRYPT_NEWKEYSET);
// ... generate the key, build the subject name blob, then:
KeyProvInfo.ProvType := PROV_RSA_AES;   // same constant, both call sites

まとめる:GUIDコンテナからパスワード保護されたPFXへ

PLCreateSelfSignedCertificateの内部の呼び出しチェーンは1本の直線をたどる:新しく生成されたGUIDにちなんで名付けられた新しい鍵コンテナを開き(並行するCI実行がコンテナ名で衝突することは決してない)、上記で説明した2つのフラグでその中にRSA鍵ペアを生成し、CertStrToNameWを通じてSubjectNameをX.500名前のブロブにエンコードし、ValidDaysから計算され単純なSYSTEMTIME形式の構造体として渡される有効期間ウィンドウとともにCertCreateSelfSignCertificateを呼ぶ。結果として得られる証明書コンテキストは、CertOpenStoreCERT_STORE_PROV_MEMORYで開かれたメモリ内証明書ストアに入る。これは純粋にPFXExportCertStoreExがそこからエクスポートするストアを持つためであり、そのAPIは単なる証明書コンテキストではなくストアハンドルに対して動作するからだ

// Each call opens a throwaway container named after a fresh GUID:
CryptAcquireContextW(hProv, PWideChar(Container), nil,
  PROV_RSA_AES, CRYPT_NEWKEYSET);
// ... generate the key, self-sign the certificate, export the PFX ...
// then delete the container once the PFX holds its own copy of the key:
CryptAcquireContextW(hProv, PWideChar(Container), nil,
  PROV_RSA_AES, CRYPT_DELETEKEYSET);

PFXExportCertStoreEx自体は通常のWin32の2パス方式の慣習に従う:まずゼロ長のバッファでそれを呼んでPFXが必要とするバイト数を知り、その分だけ割り当て、そしてもう一度呼んでバッファを埋める。バイトがディスク上に載ったら、PDFlibPasは使い捨ての鍵コンテナを残しておく代わりにCRYPT_DELETEKEYSETでそれを削除する。なぜならPFXはすでにそのコンテナが保持していた鍵素材のすべてのバイトの自身のコピーを運んでいるからだ。そのクリーンアップを省略すると、PLCreateSelfSignedCertificateへの呼び出しのたびに、呼び出し元ユーザーのプロファイル内に孤立したGUID名の鍵コンテナが残り、これはまさに、この関数をビルドごとに実行するCIエージェントが、誰も気づかないまま数ヶ月かけて蓄積していく類の漏洩である

自己署名証明書は本番の署名に使って安全か

いいえ:自己署名証明書は署名のコードパスを演習するには安全だが、チーム外の誰かが信頼することを期待される署名には安全ではない。なぜなら、それを信頼する側のソフトウェアがすでに信頼しているルートに、それを遡って結びつけるものが何もないからだ。このようなPFXにとって自然な次のステップは実際の署名呼び出しであり、これはPDFlibPasでDelphiのコンプライアンス・署名ワークベンチを構築するで扱われている。そこでは、このように構築されたPFXが、PDF/Aのプリフライトチェックと ByteRange監査も実行するパイプラインの署名の半分を駆動する。しかし署名は証明書の周りにあるものの半分にすぎず、もう半分こそが自己署名のリーフ証明書が失敗するべきまさにその場所である:DelphiでPDFlibPasを使ったPAdES署名と検証は、適合性バリデータが実行する信頼チェーンのチェックを扱っている。チェーンをたどって信頼されたルートまで戻るバリデータには、この関数が5分前に何もないところから作り出した証明書を信頼する理由が一切ない

PLCreateSelfSignedCertificateは、DelphiおよびC++Builder向けPDFlibPas PDFライブラリの証明書・署名APIの中の一つの関数であり、まさにここで説明したギャップのために存在する:その背後に本物の鍵ペアを必要とし、それを生成するのに外部の何も必要としない署名テストのためにである