.NET Core3.1で証明書ストアからクライアント証明書をPEM形式でエクスポートする

2022.07.13

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

MADグループ@大阪の岩田です。先日こんなブログを書きました。

このブログでは証明書ストアから手動エクスポートしたクライアント証明書と秘密鍵をaws_signing_helper.exeの引数で指定しています。せっかくクラアント証明書をADから自動発行しているのに、手動のエクスポート処理を挟まないとIAM Roles Anywhereが利用できないのはなんとも不便です。いちいち手動エクスポートを挟まなくて済むように、.NET Coreのプログラムから証明書ストアにアクセスして証明書&秘密鍵を出力する方法について調べてみました。

環境

今回利用した環境です

  • OS: Windows Server2019
  • .NET Core: 3.1.420
  • aws_signing_helper.exe: 1.0.0
  • AWS CLI: 2.7.13

やってみる

今回は証明書ストアから以下の証明書を取得し、PEM形式に出力してみます。対象の証明書はcm-iwata-caというCAから発行されているので、.NET CoreのプログラムからはCAの名前を使って証明書をフィルタします。

いきなりですが、以下のコードでPEM形式の証明書と秘密鍵が出力可能です。以下注意点です。

  • 今回出力対象として証明書にはパスフレーズを付けていません
  • 検証が目的なので例外のハンドリングなど省略しています。もし実際に業務で利用される場合は適宜チェック処理等を追加して下さい。
using System;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;


namespace iamanywhere
{
    class Program
    {
        const string MY_CA_NAME = "cm-iwata-ca";

        static void Main(string[] args)
        {

            X509Store store = new X509Store("My", StoreLocation.CurrentUser);
            store.Open(OpenFlags.OpenExistingOnly);
            X509Certificate2 cert = store.Certificates.OfType<X509Certificate2>().First(x =>
               x.IssuerName.Name.StartsWith($"CN={MY_CA_NAME}"));

            StringBuilder sb = new StringBuilder();

            sb.AppendLine("-----BEGIN CERTIFICATE-----");
            sb.AppendLine(Convert.ToBase64String(cert.Export(X509ContentType.Cert), Base64FormattingOptions.InsertLineBreaks));
            sb.AppendLine("-----END CERTIFICATE-----");

            string appDir = AppDomain.CurrentDomain.BaseDirectory;

            string certFileName = Path.Combine(appDir, "certificate.pem");
            string privateKeyFile = Path.Combine(appDir, "private.key");
            
            using (StreamWriter writer = new StreamWriter(certFileName))
            {
                writer.Write(sb.ToString());
            }


            sb.Clear();

            AsymmetricAlgorithm key = cert.GetRSAPrivateKey();
            byte[] privKeyBytes = key.ExportPkcs8PrivateKey();

            sb.AppendLine("-----BEGIN PRIVATE KEY-----");
            sb.AppendLine(Convert.ToBase64String(privKeyBytes, Base64FormattingOptions.InsertLineBreaks));
            sb.AppendLine("-----END PRIVATE KEY-----");

            using (StreamWriter writer = new StreamWriter(privateKeyFile))
            {
                writer.Write(sb.ToString());
            }

        }
    }
}

実装を少しずつ見ていきましょう。

X509Store store = new X509Store("My", StoreLocation.CurrentUser);
store.Open(OpenFlags.OpenExistingOnly);

まずここの処理で証明書ストアをオープンします。X509Storeのコンストラクタに"My", StoreLocation.CurrentUserを指定することで現在のユーザーの証明書ストアから個人のストアが開けます。

X509Certificate2 cert = store.Certificates.OfType<X509Certificate2>().First(x =>
	x.IssuerName.Name.StartsWith($"CN={MY_CA_NAME}"));

store.Certificatesで証明書のコレクションが取得できるので、OfTypeを使ってX509Certificate2にキャストします。これでLINQが使えるので、証明書の発行者がcm-iwata-caに合致する最初の1件を取得します。ここは必要に応じて条件を変更して下さい。また本来はFirstではなくFirstOrDefaultを結果の存在チェックを行うべきです。これで証明書が取得できるので

Convert.ToBase64String(cert.Export(X509ContentType.Cert), Base64FormattingOptions.InsertLineBreaks)

で証明書をConvert.ToBase64Stringでbase64の文字列に変換します。あとは-----BEGIN CERTIFICATE----------END CERTIFICATE-----を付与してファイルに出力すればクライアント証明書のエクスポートは完了です。

続いて秘密鍵ですが

AsymmetricAlgorithm key = cert.GetRSAPrivateKey();

で秘密鍵を取得し

byte[] privKeyBytes = key.ExportPkcs8PrivateKey();

で秘密鍵をbyteの配列にエクスポートします。あとは先程の証明書と同様に

Convert.ToBase64String(privKeyBytes, Base64FormattingOptions.InsertLineBreaks

でbase64の文字列に変換し、-----BEGIN PRIVATE KEY----------END PRIVATE KEY-----を付与してファイルに出力すれば秘密鍵のエクスポートも完了です。

証明書&秘密鍵のエクスポートからaws_signing_helper.exeの実行までまとめて実施する

これで証明書ストアから証明書&秘密鍵のエクスポートが簡単に実行できるようになりました。とはいえAWS CLI利用前に手動で前述のプログラムを起動するのは面倒です。そこでAWS CLIの設定ファイルでcredential_processに.NET Coreで作成したプログラムを指定し、.NET Coreのプログラム側で証明書&秘密鍵のエクスポートからaws_signing_helper.exeを利用した一時クレデンシャルの取得まで一連の処理を実行してみます。

先程のコードにaws_signing_helper.exeを呼び出す処理を追加すると以下のようになります。諸々のARNは本来設定ファイル等に持ちたいところですが、今回はオンコードで設定しています。

using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;


namespace iamanywhere
{
    class Program
    {
        const string MY_CA_NAME = "cm-iwata-ca";
        const string TRUST_ANCHOR_ARN = "信頼アンカーのARN";
        const string PROFILE_ROLE_ARN = "プロファイルのARN";
        const string ROLE_ARN = "IAMロールのARN";



        static void Main(string[] args)
        {

            X509Store store = new X509Store("My", StoreLocation.CurrentUser);
            store.Open(OpenFlags.OpenExistingOnly);
            X509Certificate2 cert = store.Certificates.OfType<X509Certificate2>().First(x =>
               x.IssuerName.Name.StartsWith($"CN={MY_CA_NAME}"));


            StringBuilder sb = new StringBuilder();

            sb.AppendLine("-----BEGIN CERTIFICATE-----");
            sb.AppendLine(Convert.ToBase64String(cert.Export(X509ContentType.Cert), Base64FormattingOptions.InsertLineBreaks));
            sb.AppendLine("-----END CERTIFICATE-----");

            string appDir = AppDomain.CurrentDomain.BaseDirectory;

            string certFileName = Path.Combine(appDir, "certificate.pem");
            string privateKeyFile = Path.Combine(appDir, "private.key");
            using (StreamWriter writer = new StreamWriter(certFileName))
            {
                writer.Write(sb.ToString());
            }


            sb.Clear();

            AsymmetricAlgorithm key = cert.GetRSAPrivateKey();
            byte[] privKeyBytes = key.ExportPkcs8PrivateKey();

            sb.AppendLine("-----BEGIN PRIVATE KEY-----");
            sb.AppendLine(Convert.ToBase64String(privKeyBytes, Base64FormattingOptions.InsertLineBreaks));
            sb.AppendLine("-----END PRIVATE KEY-----");

            using (StreamWriter writer = new StreamWriter(privateKeyFile))
            {
                writer.Write(sb.ToString());
            }


            using (Process process = new Process())
            {
                ProcessStartInfo info = process.StartInfo;
                info.FileName = Path.Combine(appDir, "aws_signing_helper.exe");
                info.UseShellExecute = false;
                info.RedirectStandardOutput = true;
                info.ArgumentList.Add("credential-process");
                info.ArgumentList.Add("--certificate");
                info.ArgumentList.Add(certFileName);
                info.ArgumentList.Add("--private-key");
                info.ArgumentList.Add(privateKeyFile);
                info.ArgumentList.Add("--trust-anchor-arn");
                info.ArgumentList.Add(TRUST_ANCHOR_ARN);
                info.ArgumentList.Add("--profile-arn");
                info.ArgumentList.Add(PROFILE_ROLE_ARN);
                info.ArgumentList.Add("--role-arn");
                info.ArgumentList.Add(ROLE_ARN);
                process.Start();

                using (StreamReader reader = process.StandardOutput)
                {
                    string output = reader.ReadToEnd();
                    Console.WriteLine(output);
                }
                process.WaitForExit();
            }
        }
    }
}

EXEファイルと同一階層aws_signing_helper.exeを配置しつつこのプログラムを実行すると、証明書と秘密鍵のエクスポート後にaws_signing_helper.exeを起動し、取得したクレデンシャル情報を標準出力に書き出してくれます。あとは以下のように.aws/configcredential_processに上記のプログラムを指定すれば証明書ストアからのエクスポート処理を意識することなくAWS CLIが叩けるようになります。

[default]
region = ap-northeast-1
output = json

[profile iwata]
    credential_process = C:\Users\iwata\.aws\iamanywhere.exe

むしろaws_signing_helper.exeを自作したい

ここまでで証明書ストアからの手動エクスポート処理が不要になりましたが、PEM形式の証明書と秘密鍵を都度ファイルに出力するのはあまり良いやり方とは言えません。できればいちいちファイルに出力することなくaws_signing_helper.exeを実行したいところです。が、現状aws_signing_helper.exeに証明書と秘密鍵を渡すにはファイルを渡すしか無いようで、コマンドライン引数でbase64エンコードされた文字列をそのまま渡すといった使い方はできないようです。

ということでaws_signing_helper.exeと同等の処理を.NET Coreで実装できないか考えてみました。まずaws_signing_helper.exe--debugオプション付きで実行すると以下のような出力が得られます。

2022/07/13 17:06:12 DEBUG: Request Roles Anywhere/CreateSession Details:
---[ REQUEST POST-SIGN ]-----------------------------
POST /sessions?profileArn=<プロファイルのARNをURLエンコードした文字列>&roleArn=<IAMロールのARNをURLエンコードした文字列>&trustAnchorArn=<信頼アンカーのARNをURLエンコードした文字列> HTTP/1.1
Host: rolesanywhere.ap-northeast-1.amazonaws.com
User-Agent: CredHelper/1.0.0 (go1.18; darwin; amd64)
Transfer-Encoding: chunked
Authorization: AWS4-X509-RSA-SHA256 Credential=713623846402048767132522899098360803183558665/20220713/ap-northeast-1/rolesanywhere/aws4_request, SignedHeaders=content-type;host;x-amz-date;x-amz-x509, Signature=4e4ba91f14ce753a2e18cde90e229df7ce53634afdd84c043a2b3cf71f0bf79cd4f841d46db05ee1bcc548e150edb10946398848237be5f762547846cab78ab98f9411a992d01a5d3a96f263e8b9fbbb3b490407b9772adf6467197d1d8fc7c752e67942ade7ed2bac866fb1e387a332beb8ee81a72e9aa829caf81446b0a0be56407d3296b5f97b138763336a77dabc3ed45af223bfc59fbb5b2513fe680d784877671e6a5bd1dd0709523ee1ecc23d2909f776448546a35f3d53329356353b263bff53e91683deadd55572da40596724aaeed07f8bf97aaa15550395252443f1b3e40ec6788bd05a9e9a01aa70db6febe8452b3339c1591161200bc7620934
Content-Type: application/json
X-Amz-Date: 20220713T080612Z
X-Amz-X509: <クライアント証明書をbase64エンコードした文字列>
Accept-Encoding: gzip


-----------------------------------------------------
2022/07/13 17:06:12 DEBUG: Response Roles Anywhere/CreateSession Details:
---[ RESPONSE ]--------------------------------------
HTTP/1.1 201 Created
Content-Length: 1815
Connection: keep-alive
Content-Type: application/json
Date: Wed, 13 Jul 2022 08:06:12 GMT
X-Amzn-Requestid: 9cc744d0-38b9-4ce9-9ef5-d467e98dbf5a

この出力...どこかで見覚えが無いでしょうか?AWS CLIのデバッグ出力にそっくりなんです。Authorizationヘッダの中身を見ると、SIGV4の署名プロセスと同様のプロセスで署名を作っているように見えます。通常のSIGV4との違いを考えると以下の項目が差分のようです。

  1. AWS4-HMAC-SHA256ではなくAWS4-X509-RSA-SHA256が指定されている
  2. Credential=の次にアクセスキーIDではなく7136...という謎の数字が指定されている
  3. Signature=の次に指定されている署名の文字列長が512
  4. X-Amz-X509というヘッダでクライアント証明書の中身を送信しており、SignedHeadersにもx-amz-X509が含まれている

このうち2つ目のCredential=の後に続く数字については証明書の中身を色々と分析したところ、証明書のシリアルNoを10進数に変換した数値であることが分かりました。署名文字列に関してはAWS4-HMAC-SHA256AWS4-X509-RSA-SHA256になっている以外は通常のSIGV4と同様のルールで計算しているように見えます。あとはハッシュアルゴリズムにAWS4-HMAC-SHA256ではなくAWS4-X509-RSA-SHA256を利用して署名が計算できれば自前でaws_signing_helper.exeと同様のプログラムが実装できそうです。これができればいちいち証明書と秘密鍵をファイルに出力しなくても一時クレデンシャルが取得できるはず...

と思って色々試したのですが、結局AWS4-X509-RSA-SHA256がどういうアルゴリズムで署名を作っているのか分かりませんでした。秘密鍵を使ってSHA256のハッシュを計算してゴニョゴニョしてるんだとは思いますが、具体的にどういう計算をしているか分かりませんでした。AWS4-HMAC-SHA256では擬似コード

kSecret = your secret access key
kDate = HMAC("AWS4" + kSecret, Date)
kRegion = HMAC(kDate, Region)
kService = HMAC(kRegion, Service)
kSigning = HMAC(kService, "aws4_request")

で署名を計算しているようですが、AWS4-X509-RSA-SHA256だとどうなるんでしょう?とりあえず単純にopensslコマンドでopenssl dgst -sha256 -sign <秘密鍵> -hex <署名対象文字列>しただけだとダメでした。他にも色々試したのですが、結局デバッグ出力と同等の署名を得るには至りませんでした。もしAWS4-X509-RSA-SHA256のアルゴリズムをご存知の方がいれば是非教えて頂きたいです。

まとめ

aws_signing_helper.exe相当のツールを自作できれば良かったのですが、そこまでは力及びませんでした。そのうちドキュメントでAWS4-X509-RSA-SHA256のアルゴリズムが公開されるのを待ちたいと思います。

参考