Ubuntu+Nginx (+PHP) でクライアント証明書を発行するWebサイト


防備録です。
PHPから openssl を叩いていますが、直接 openssl を叩くときのコマンドもコメントに併記しています。

最終的にしたいこと

以下のような流れです。

  • サーバが鍵ペアを発行する。
  • クライアントは鍵ペアをブラウザに登録する。
  • 以降はその公開鍵を用いてユーザを識別・認証する。

準備

Ubuntuの準備

 ConoHa で Ubuntu20.04 をインストールし、ユーザを追加した状態からスタートします。この例では、ユーザ名は yukatayu 、ドメインが ssl-test.yukatayu.tech で登録してある状態で操作しています。適宜読み替えてください。

 とりあえず使いそうなものを入れます。特に nginx と openssl と letsencrypt が入っていれば良さそう。PHP は、とりあえず apt で楽に入る php7.4-fpm を入れました。

sudo apt update
sudo apt upgrade -y
sudo apt install -y vim tree make git nginx openssl php7.4-fpm letsencrypt

CA鍵の生成

 とりあえず作業フォルダを作ります。

sudo mkdir /var/client_cert
sudo chown yukatayu /var/client_cert
cd /var/client_cert

 鍵ペアを作ります。楕円曲線の名前は 512 と見せかけて 521 なので注意してください。

openssl ecparam -genkey -name secp521r1 -out ca.key

 鍵ペアにパスワードを付けます。今回は yukatayu にしました。

openssl ec -in ca.key -out ca.key -aes256

 鍵ペアを元に証明書を作ります。とりあえず有効期限は100年にします。

openssl req -new -x509 -days 36500 -key ca.key -out ca.crt

 パラメータは以下のようにしました

項目 自分が設定した値
Country Name JP
State or Province Name Tokyo
Locality Name Meguro
Organization Name Yukatayu Project
Organizational Unit Name Engineering Department
Common Name ssl-test.yukatayu.tech
Email Address (自分のメアド)

 読み取り権限をつけておきます。実際の運用ではセキュリティをもっといい感じにしておいてください。

chmod a+r ca.*

nginx のセットアップ

 面倒なので、このセクションだけは root 権限で操作します。

sudo su -
cd /etc/nginx

 サーバ証明書を発行します。とりあえずメアドを設定しないモードにしています。
設定したい場合は --non-interactive--register-unsafely-without-email を外してください。

certbot certonly \
    --standalone \
    --non-interactive \
    --agree-tos \
    --register-unsafely-without-email \
    --domains ssl-test.yukatayu.tech \
    --pre-hook 'systemctl stop nginx' \
    --post-hook 'systemctl start nginx'

ここで

IMPORTANT NOTES:
- Congratulations! Your certificate and chain have been saved at ...

みたいなことを言われたら成功しています。

次に nginx を雑に設定していきます。

vim sites-available/ssl-test.yukatayu.tech

ssl-test.yukatayu.tech (クリックして展開)
server {
    listen 80; 
    listen [::]:80;
    server_name ssl-test.yukatayu.tech;
    location / { 
        return 302 https://$host$request_uri;   #301でも良さそう
    }   
}

server {
    server_name ssl-test.yukatayu.tech;
    root /var/www;

    ssl_certificate /etc/letsencrypt/live/ssl-test.yukatayu.tech/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/ssl-test.yukatayu.tech/privkey.pem;

    ssl_client_certificate "/var/client_cert/ca.crt";
    ssl_verify_client optional;  # onだと常時

    location / { 
        # 認証不要
    }   

    location /private/ {
        # 認証が必要
        if ($ssl_client_verify != SUCCESS) {
            return 403;
        }
    }   

    location ~ \.php$ {
        #fastcgi_pass   127.0.0.1:9000;
        fastcgi_pass   unix:/run/php/php7.4-fpm.sock;
        fastcgi_index  index.php;
        fastcgi_param  SCRIPT_FILENAME  $document_root$fastcgi_script_name;
        include    fastcgi_params;
        fastcgi_param SSL_CLIENT_I_DN $ssl_client_i_dn;
        fastcgi_param SSL_CLIENT_S_DN $ssl_client_s_dn;
        fastcgi_param SSL_CLIENT_F_PR $ssl_client_fingerprint;
    }   

    include /etc/nginx/snippets/ssl.conf;
}

 最初の8行は、 http:// にアクセスが来たら https:// にリダイレクトする設定で、必須ではないです。
 その後にある ssl_certificatessl_certificate_keyはサーバ証明書、 ssl_client_certificatessl_verify_client はクライアント証明書の設定です。
 その下の行で、 /private/ のみ認証が必要にしています。そして2つ目のポイントで、 fastcgi_param が3つ並んでいる箇所は、クライアント認証の認証情報を PHP に受け渡しています。詳しくは 公式サイト を参照してください。

次に、SSL のいつもの設定をしていきます。

vim snippets/ssl.conf

ssl.conf (クリックして展開)
    listen 443 ssl http2;
    listen [::]:443 ssl http2;

    ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
    ssl_prefer_server_ciphers on; 

    ssl_session_cache shared:le_nginx_SSL:1m;
    ssl_session_timeout 1d; 

    ssl_session_tickets off;

    gzip on; 
    gzip_types
        text/plain
        text/xml
        text/css
        application/xml
        application/xhtml+xml
        application/rss+xml
        application/atom_xml
        application/javascript
        application/x-javascript
        application/x-httpd-php;
    gzip_disable    "MSIE [1-6]\.";
    gzip_disable    "Mozilla/4";
    gzip_comp_level 1;
    gzip_proxied    any;  
    gzip_vary   on; 
    gzip_buffers    4 8k; 
    gzip_min_length 1100;
    index  index.html index.htm index.php;

    ## Static Resources
    location ~* \.(css|js|jpeg|jpg|gif|png|ico)$ {
        expires 3d;
        break;
    }

    location ~ /\.ht { deny  all; }
    location = /robots.txt { access_log off; log_not_found off; }
    location = /favicon.ico { access_log off; log_not_found off; }


    # OCSP Stapling --- 
    # fetch OCSP records from URL in ssl_certificate and cache them
    ssl_stapling on; 
    ssl_stapling_verify on; 

    ssl_ciphers 'ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA256:DHE-RSA-AES256-SHA:ECDHE-ECDSA-DES-CBC3-SHA:ECDHE-RSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:DES-CBC3-SHA:!DSS!DH';

 ssl_ciphers は好みに合わせて変更してください。ラインナップが少し古めですが、現時点(2020年)では十分強いはずです。

 設定ファイルを有効化します。

cd sites-enabled
rm default
ln -s /etc/nginx/sites-available/ssl-test.yukatayu.tech

 設定ファイルをチェックします。

nginx -t
# > nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
# > nginx: configuration file /etc/nginx/nginx.conf test is successful

大丈夫そうなら nginx を再起動します。

systemctl restart nginx

PHP を書く

 su 状態の人は戻ってください。別に戻らなくてもいいですけれど。
 ここからはopensslを叩くだけです。とりあえず nginx で root に指定した位置にフォルダを作ります。

mkdir -p /var/www
sudo chown yukatayu /var/www
cd /var/www

鍵発行部分の作成

vi keygen.php

 コードを書きますが、PHPのコードをコマンドと勘違いすることは無いと思うので折りたたみ無しで書きます。流れとしては先ほどと同じですが、ルートCA証明書で署名する工程と、 pfx ファイルに固める工程が増えています。

 $dnopenssl_csr_sign の第 4, 6 引数、及び $password$friendlyName は、発行する相手に合わせて変えてください。

 あと、 $caPrevPw は、先程CAキーに付けたパスワードです。この場合は yukatayu です。

<?php
// クライアント証明書用の鍵
// openssl ecparam -genkey -name secp521r1 -out ca.key
// RSA が好きなら openssl genrsa -des3 -out user.key 4096
$privateKey =
    openssl_pkey_new([
        'private_key_bits' => 512,  // RSAの 15360 bit くらいの強さらしい
        'private_key_type' => OPENSSL_KEYTYPE_EC,
        'curve_name' => 'secp521r1',
        // RSA が好きなら
        // 'private_key_bits' => 4096,
        // 'private_key_type' => OPENSSL_KEYTYPE_RSA,
    ]);

// 署名
// openssl req -new -key user.key -out user.csr
$dn = [
    'countryName' => 'JP',
    'stateOrProvinceName' => 'Tokyo',
    'localityName' => 'Meguro',
    'organizationName' => 'Yukatayu',
    'organizationalUnitName' => 'Some Team',
    'commonName' => 'yukatayu.tech',
    'emailAddress' => '[email protected]',
];

$csr =
    openssl_csr_new(
        $dn,
        $privateKey,
        [
            'digest_alg' => 'sha256',
        ]);

// CSRの署名
// openssl x509 -req -days 365 -in user.csr -CA ca.crt -CAkey ca.key -set_serial 01 -out user.crt
$caCert = '/var/client_cert/ca.crt';
$caPrev = '/var/client_cert/ca.key';
$caPrevPw = 'yukatayu';

$x509 =
    openssl_csr_sign(
        $csr,
        "file://{$caCert}",
        ["file://{$caPrev}", $caPrevPw],
        3650,  // days
        ['digest_alg' => 'sha256'],
        1  // serial
    );

// PKCS #12 (PFX)の作成
// openssl pkcs12 -export -out user.pfx -inkey user.key -in user.crt -certfile ca.crt
$password = 'nyan';
$friendlyName = 'Yukatayu Secret Key';

$pkcs12 = null;
openssl_pkcs12_export(
    $x509,
    $pkcs12,
    $privateKey,
    $password,
    [
        // 'extracerts' => $CAcert,
        'friendly_name' => $friendlyName,
    ]);

// 出力
header('Content-Type: application/x-pkcs12');
header('X-Content-Type-Options: nosniff');
header('Content-Length: ' .strlen($pkcs12));
header('Content-Disposition: attachment; filename="yukatayu_tech_client.pfx"');
header('Connection: close');
print($pkcs12);
exit();

 curve_nameprint_r(openssl_get_curve_names()); などをすることで分かります。また、対応する openssl のコマンドをコメントで記述しておきましたので、これらをして手で生成することができます。

private エリアの生成

mkdir private
cd private
vim index.php

 とりあえず認証情報を雑に表示するだけです。クライアント証明書なしの場合は nginx で弾かれます。それと、コメント行は表示例です。

<pre>
Welcome:

SSL_CLIENT_I_DN: <?= $_SERVER['SSL_CLIENT_I_DN'] ?>
<!-- [email protected],CN=ssl-test.yukatayu.tech,OU=Engineering Department,O=Yukatayu Project,L=Meguro,ST=Tokyo,C=JP -->

SSL_CLIENT_S_DN: <?= $_SERVER['SSL_CLIENT_S_DN'] ?>
<!-- [email protected],CN=yukatayu.tech,OU=Some Team,O=Yukatayu,L=Meguro,ST=Tokyo,C=JP -->

SSL_CLIENT_F_PR: <?= $_SERVER['SSL_CLIENT_F_PR'] ?>
<!-- b6b84c9573998e096d1bea593c79b0dbae862145 -->

動作確認

アクセス制限の確認

 とりあえず ssl-test.yukatayu.tech/private/ にアクセスしてみると 403 が返ります。

鍵ペアを発行する

 ブラウザから ssl-test.yukatayu.tech/keygen.php にアクセスすると pfx ファイルが降ってきます。

鍵ペアをブラウザに登録する

 私はとりあえずFirefox派なので、その手順を説明します。

 まずブラウザの設定を開きます。「プライバシとセキュリティ」から「証明書を表示…(C)」を押します。「あなたの証明書」から「インポート(M)…」を押し、先程の pfx ファイルを開きます。
 上記のコードでは $password = 'nyan'; となっているので、パスワードの nyan を入力するとインポートできます。

アクセスしてみる

 ssl-test.yukatayu.tech/private/ にアクセスしてみると、「個人証明書の要求」というプロンプトが出るはずです。 出ない場合は Shiftを押しながら リロードしてみてください。
 先程インポートした証明書を選択して OK を押すと、多分アクセスできるはずです。

おわりに

 クライアント証明書が流行らない理由がわからないので、誰か教えてください。
 それと今回のは説明用なのでなんか雑です。実戦投入したい場合はセキュリティの専門家にきちんと相談してください。