CIS (IBM Cloud Internet Service) を使ってmTLSを構成してみた


1. はじめに

mTLS(相互TLS/MTLS)を用いたセキュアな通信を実装するため、以下の記事を参考にさせていただき試してみたので、自分なりに手順の整理として本記事にまとめます。※下記手順において実際に作業した環境を【】で示します。

CIS(IBM Cloud Internet Service)でAuthenticated Origin Pull(認証済みOrigin Pull)を構成してみました
https://qiita.com/JoeToyoda/items/434a2ad9ede7bd3cb429

2. 試したこと

クライアントからバックエンドサーバー(起点)に対するHTTPS通信においてCISを噛ませることで、クライアントとCIS間・CISとバックエンドサーバー(起点)間でそれぞれmTLSを構成しました。

  • 今回は白のルート証明書・秘密鍵、オレンジのクライアント証明書・秘密鍵、黄色の起点/サーバー証明書・秘密鍵を用意しました。
  • 青のサーバールート証明書に値するルート証明書は、DigiCert社が発行しているルート証明書が必要となりますが、主要なWebブラウザやOSにプリインストールされているため今回は準備していません。プリインストールされていないクライアントを利用する場合は、ブラウザからダウンロードできます。
  • CISが保持している必要のある緑の証明書・秘密鍵はCISが内部的に保持していてよしなに差し出して認証してくれるため準備は不要です。
  • 今回は自分の環境だけで完結する検証でしたので、クライアントとCIS間・CISとバックエンド間ともに同じクライアント証明書・秘密鍵(オレンジ)を使用しましたが、実際のケースではクライアントが別の管理者となる場合、先方のクライアント証明書に署名したルート証明書を入手しCISにアップロードしてあげる必要があります。

mTLSについて詳しくは以下のサイトを参照ください。

mutual-TLS(mTLS, 2way TLS)相互認証の仕組み ~クライアント認証とトークンバインディング over http
https://milestone-of-se.nesuke.com/nw-basic/tls/mutual-tls-token-binding/

3. 事前準備

バックエンドサーバー(起点)準備【IBM Cloudポータル(ダッシュボード)】

IBM CloudのVSI(仮想サーバー)を利用。ここでの手順の記載は省きますが、後ほど必要になるため浮動IPの設定を行ってください。
また、Webサーバーとして使う予定の443ポートでクライアントからのHTTPS通信を受けられるようにセキュリティ・グループで以下に記載のIPをソースとして許可してください。
https://cloud.ibm.com/docs/cis?topic=cis-cis-allowlisted-ip-addresses

4. クライアント証明書準備【クライアントCLI】

4.1 自己署名証明書(ルート証明書)作成

# 秘密鍵作成,CSR作成,自己署名を一度にする(秘密鍵を暗号化しない)
$ openssl req -new -x509 -nodes -days 365 -subj '/CN=hyama-ca' -keyout ca.key -out ca.crt
# ルート証明書の内容確認
$ openssl x509 -in ca.crt -text -noout
Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number:
            d5:3e:63:9d:25:e3:ac:0f
    Signature Algorithm: sha256WithRSAEncryption
        Issuer: CN=hyama-ca
        Validity
            Not Before: Jul 27 01:58:25 2021 GMT
            Not After : Jul 27 01:58:25 2022 GMT
        Subject: CN=hyama-ca
(中略)
                        Exponent: 65537 (0x10001)
        X509v3 extensions:
            X509v3 Subject Key Identifier: 
                95:89:C5:6C:01:BE:3F:62:0E:71:4B:88:58:61:E7:04:33:1A:52:BD
            X509v3 Authority Key Identifier: 
                keyid:95:89:C5:6C:01:BE:3F:62:0E:71:4B:88:58:61:E7:04:33:1A:52:BD


            X509v3 Basic Constraints: 
                CA:TRUE
.....

上記出力では、Issuer(発行者)とSubject(所有者)が同じ値になっているため自己署名証明書であることがわかります。
Issuer: CN=hyama-ca
Subject: CN=hyama-ca

4.2 クライアント証明書作成

# クライアント秘密鍵作成
$ openssl genrsa -out client.key
# CSR作成
$ openssl req -new -subj '/CN=localhost' -key client.key -out client.csr
# ルート証明書が署名したクライアント証明書作成
$ openssl x509 -req -CA ca.crt -CAkey ca.key -CAcreateserial -days 365 -in client.csr -out client.crt
# クライアント証明書の内容確認
$ openssl x509 -in client.crt -text -noout
Certificate:
    Data:
        Version: 1 (0x0)
        Serial Number:
            f2:1d:40:a1:93:f2:34:83
    Signature Algorithm: sha256WithRSAEncryption
        Issuer: CN=hyama-ca
        Validity
            Not Before: Jul 27 02:55:53 2021 GMT
            Not After : Jul 27 02:55:53 2022 GMT
        Subject: CN=localhost
.....

上記出力では、Issuer(発行者)はルート証明書のIssuerと同じ値、Subject(所有者)はlocalhostとなっていることがわかります。
Issuer: CN=hyama-ca
Subject: CN=localhost

作成されたクライアント証明書関連のファイルは以下の通りです。

証明書 説明
ca.crt クライアント証明書に署名したルート証明書 
ca.key クライアント証明書に署名したルート証明書の秘密鍵 
ca.srl -CAcreateserialオプションにより作成されるCAのシリアル番号を保持するファイル 
client.crt クライアント証明書 
client.key クライアント秘密鍵 
client.csr CSR 

5. クライアントとCIS間のmTLS設定【CIS UI】

ドメインの追加などCIS利用開始のセットアップは完了している前提で手順を記載します。設定方法などはこちらを参照ください。
また、以下手順はドメイン名を「mtls-test.net」と仮定して記載します。

5.1 グローバル・ロード・バランサーの構成

5.1.1 起点プール作成

「信頼性」セクションに移動し、「グローバル・ロード・バランサー」タブの「起点プール」セクションに移動し「作成」ボタンをクリックします。
下記項目を設定し、「保存」ボタンをクリックします。

プール名: mtls_pool(任意の名前を指定)
起点名: mtls_origin(任意の名前を指定)
起点アドレス: バックエンドサーバー(起点)のIPアドレス(浮動IP)を指定
ヘルス・チェック: ヘルス・チェックなし
※上記以外の項目はデフォルトのまま設定しています

5.1.2 ロード・バランサー作成

「ロード・バランサー」セクションに移動し「作成」ボタンをクリックします。
下記項目を設定し、「作成」ボタンをクリックします。

プロキシー: オン
名前: mtls_lb(任意の名前を指定)
地理的経路: 「経路の追加」ボタンをクリックし、作成した起点プール(mtls_pool)を選択し「追加」をクリック
※上記以外の項目はデフォルトのまま設定しています

5.2 相互TLSの設定

相互TLSはデフォルトでは有効になっていないため、相互TLSを有効化する必要があります。設定方法はこちらを参照ください。以下手順は相互TLSが有効になっている前提で記載します。

5.2.1 クライアントルート証明書の追加

「セキュリティ」セクションに移動し、「相互TLS」タブの「ルート証明書」セクションに移動し「追加」ボタンをクリックします。
下記項目を設定し、「保存」ボタンをクリックします。

証明書名: mtls_ca(任意の名前を指定)
証明書の内容: クライアント証明書に署名したルート証明書(ca.crt)の内容を貼り付けます ※中間証明書を使用している場合はルート証明書と中間証明書のチェーンを貼り付けます
関連付けられたホスト名: 作成したロード・バランサーの名前をドメイン付き(mtls_lb.mtls-test.net)で指定

5.2.2 MTLSアクセス・ポリシーの追加

「MTLSアクセス・ポリシー」セクションに移動し「追加」ボタンをクリックします。
下記項目を設定し、「保存」ボタンをクリックします。

アプリケーション名: mtls_app(任意の名前を指定)
アプリケーション・ドメイン名: ルート証明書に設定した「関連付けられたホスト名」(mtls_lb.mtls-test.net)を指定
アプリケーション・パス: /test1(mTLS対象のバックエンドサーバーのエンドポイントのパスを指定)

6. サーバー証明書作成【CIS UI】

6.1 バックエンドサーバー証明書(起点証明書)の作成

「セキュリティ」セクションの「起点」タブに移動し「注文」ボタンをクリックします。
下記項目を設定し、「注文」ボタンをクリックします。

秘密鍵のタイプ: RSA
証明書ホスト名: mtls_bkend.mtls-test.net(ドメインを含む任意のホスト名を指定)
証明書の有効期限: 15年

生成されたバックエンドサーバー(起点)証明書と秘密鍵を以下ファイル名でバックエンドサーバー(起点)上に保存します。
※ここで必ず保存してください。

証明書 説明
mtls_bkend.crt サーバー証明書 
mtls_bkend.key サーバー秘密鍵 

7. CISとバックエンドサーバー(起点)間のmTLS設定【クライアントCLI】

7.1 クライアント証明書のアップロード

# ibmcloudコマンドの最新化
$ ibmcloud update
# CIS pluginの導入
$ ibmcloud plugin install cloud-internet-services

# クライアント証明書の改行コードを書き換え
$ MYCERT="$(cat client.crt|perl -pe 's/\r?\n/\\n/'|sed -e 's/..$//')"
# クライアント秘密鍵の改行コードを書き換え
$ MYKEY="$(cat client.key|perl -pe 's/\r?\n/\\n/'|sed -e's/..$//')"

# ibmcloudコマンドに渡すjsonファイル(reqbody.json)を作成
$ cat <<EOF > reqbody.json
> { "certificate": "$MYCERT\n","private_key": "$MYKEY\n"}
> EOF

# IBM Cloudにログイン
$ ibmcloud login 
# 設定するドメインIDの確認
$ ibmcloud cis domains -i "<cisのインスタンス名>"

# クライアント証明書・秘密鍵のアップロード(ゾーン・レベル)
$ ibmcloud cis authenticated-origin-pull-certificate-upload <ドメインID> --json @reqbody.json
# アップロードした証明書の確認
$ ibmcloud cis authenticated-origin-pull-certificates <ドメインID>

7.2 認証済みOrigin Pullの設定

# アップロードした証明書を使ってAuthenticated Origin Pullを設定
$ ibmcloud cis authenticated-origin-pull-settings-update <ドメインID> --enabled on

8. バックエンドサーバー(起点)設定【VSI】

8.1 Webサーバーのセットアップ

バックエンドサーバー(起点)として準備していたVISにNode.jsでWebサーバーをセットアップします。

yum install -y nodejs
npm install express
npm install log4js

クライアント上で作成したルート証明書(ca.crt)をVSI上のバックエンドサーバー(起点)証明書関連のファイルと同じフォルダにコピーします。
同フォルダに以下のコードを配置します。

// index.js
const express = require('express');
const http = require('http');
const https = require('https');
const fs = require('fs');
const options = {
  ca: fs.readFileSync('ca.crt'), // ルート証明書(必要に応じてファイル名書き換え)
  cert: fs.readFileSync('mtls_bkend.crt'), // サーバーの証明書(必要に応じてファイル名書き換え)
  key: fs.readFileSync('mtls_bkend.key'), // サーバーの秘密鍵(必要に応じてファイル名書き換え)
  rejectUnauthorized: true, // クライアント認証に失敗するとリジェクト
  requestCert: true, // クライアント認証を実施
};
var log4js = require('log4js');
log4js.configure({
  appenders: {
    system: {type: 'file',filename: 'system.log'},  // システムログ
    access: {type: 'file',filename: 'access.log'}   // アクセスログ
  },
  categories: {
    default: {appenders: ['system'],level: 'debug'},
    access: {appenders: ['access'],level: 'info'}
  }
});
var systemLogger = log4js.getLogger();
var accessLogger = log4js.getLogger('access');
const app = express();
app.use(log4js.connectLogger(accessLogger));
app.get('/test1',(req, res) => {           // アクセス先のエンドポイントのパス
  res.status(200).json({
    message: 'Hello mtls'
  });
});


//http.createServer(app).listen(8080,() => {
//  console.log('HTTP listening on 8080....');
//  systemLogger.info('HTTP start');
//});
https.createServer(options,app).listen(443, () => {
  console.log('HTTPS listening on 443....');
  systemLogger.info('HTTPS start');
});

以下を実行しWebサーバーを起動します。

$ node index.js
HTTP listening on 443....

ターミナルからログアウトしてもWebサーバーを起動させておきたい場合は以下を実行します。
nohup.outがプログラムを実行しているディレクトリに作成され、標準出力と標準エラー出力が書き込まれます。

$ nohup node index.js &
[1] 4538

9. mTLSテスト【クライアントCLI】

curlを使ってクライアントからバックエンドサーバー(起点)への接続テストをし、mTLSが成功することを確認します。

クライアント証明書と秘密鍵を指定すると正常にアクセスが成功します。

$ curl -v --key ./client.key --cert ./client.crt https://mtls_lb.mtls-test.net:443/test1
*   Trying xxx.xx.x.xxx...
* TCP_NODELAY set
* Connected to mtls_lb.mtls-test.net (xxx.xx.x.xxx) port 443 (#0)
* ALPN, offering h2
* ALPN, offering http/1.1
* successfully set certificate verify locations:
*   CAfile: /etc/ssl/cert.pem
  CApath: none
* TLSv1.2 (OUT), TLS handshake, Client hello (1):
* TLSv1.2 (IN), TLS handshake, Server hello (2):
* TLSv1.2 (IN), TLS handshake, Certificate (11):
* TLSv1.2 (IN), TLS handshake, Server key exchange (12):
* TLSv1.2 (IN), TLS handshake, Request CERT (13):
* TLSv1.2 (IN), TLS handshake, Server finished (14):
* TLSv1.2 (OUT), TLS handshake, Certificate (11):
* TLSv1.2 (OUT), TLS handshake, Client key exchange (16):
* TLSv1.2 (OUT), TLS handshake, CERT verify (15):
* TLSv1.2 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.2 (OUT), TLS handshake, Finished (20):
* TLSv1.2 (IN), TLS change cipher, Change cipher spec (1):
* TLSv1.2 (IN), TLS handshake, Finished (20):
* SSL connection using TLSv1.2 / ECDHE-ECDSA-CHACHA20-POLY1305
* ALPN, server accepted to use h2
* Server certificate:
*  subject: C=US; ST=California; L=San Francisco; O=Cloudflare, Inc.; CN=mtls-test.net
*  start date: Aug 11 00:00:00 2021 GMT
*  expire date: Aug 10 23:59:59 2022 GMT
*  subjectAltName: host "mtls_lb.mtls-test.net" matched cert's "*.mtls-test.net"
*  issuer: C=US; O=Cloudflare, Inc.; CN=Cloudflare Inc ECC CA-3
*  SSL certificate verify ok.
* Using HTTP2, server supports multi-use
* Connection state changed (HTTP/2 confirmed)
* Copying HTTP/2 data in stream buffer to connection buffer after upgrade: len=0
* Using Stream ID: 1 (easy handle 0x7faeb3009e00)
> GET /test1 HTTP/2
> Host: mtls_lb.mtls-test.net
> User-Agent: curl/7.64.1
> Accept: */*
> 
* Connection state changed (MAX_CONCURRENT_STREAMS == 256)!
< HTTP/2 200 
< date: Tue, 07 Sep 2021 02:48:54 GMT
< content-type: application/json; charset=utf-8
< content-length: 24
< set-cookie: CF_Authorization=eyJraWQiOiI2YzNiZmZlZjcxYmIwYTkwYzljYmVmM2I3YzBkNGExYzdiNGI4Yjc2YjgwMjkyYTYyM2FmZDlkYWM0NWQxYzY1IiwiYWxnIjoiUlMyNTYiLCJ0eXAiOiJKV1QifQ.eyJ0eXBlIjoiYXBwIiwiYXVkIjoiMGE4OTJlNGU1ZDBiMzQzM2IwYjZjODA0OThlOWRlZTlkMzEwNzhhODNlZjBkNzMwOTNhOTYzODlhMWZkOTgyNCIsImV4cCI6MTYzMTA2OTMzNCwiaXNzIjoiaHR0cHM6XC9cL2ZiNDc0Mzk2Yjk5NDM0NGE2NWNiYTgzMTg5ZjYwZDA2LmNsb3VkZmxhcmVhY2Nlc3MuY29tIiwiY29tbW9uX25hbWUiOiJsb2NhbGhvc3QiLCJpYXQiOjE2MzA5ODI5MzQsInN1YiI6IkNOPWxvY2FsaG9zdCJ9.f4XcIXtWPoufxBzoctHTLc_5eiY6XD7SMKqD1yFU4MZ-TSCWzjPd4tp4HA5Qy2pkYGj6bVItXuzHES7YsPLvgJKw_LSt_q3PUC7qOXBh70GJOVLZWxkE3xptliVOTSp2ym0NWnSeTEct77nkQhuNkNBETvNt0jz9tlANmiputD9GSioSxWu4OvDJfpyDJXl7-wz6fX5p-E_Yz1VH7qFBXw81KZMrH2Pu2-32xvIv0Lc_8oTiSkWpZXeWQM95EpLKfJ6sA5JpiHvbSx4mNml22TeYTxFqMxatJFSUXkjixbm9rF6sI73LXzheMNstcG2DgGRwDMkApCQmmTuseYrM0Q; Expires=Wed, 08 Sep 2021 02:48:54 GMT; Path=/; Secure; HttpOnly
< x-powered-by: Express
< etag: W/"18-g/xxyjKO9ZXN0/7BZTmgDKWVQBE"
< cf-cache-status: DYNAMIC
< expect-ct: max-age=604800, report-uri="https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct"
< server: cloudflare
< cf-ray: 68ac9eeb0dbc1d87-NRT
< 
* Connection #0 to host mtls_lb.mtls-test.net left intact
{"message":"Hello mtls"}* Closing connection 0

クライアント証明書と秘密鍵なしでアクセスするとCloudflare Access Error(403エラー)となりアクセスに失敗します。

$ curl -v https://mtls_lb.mtls-test.net:443/test1
*   Trying xxx.xx.x.xxx...
* TCP_NODELAY set
* Connected to mtls_lb.mtls-test.net (xxx.xx.x.xxx) port 443 (#0)
* ALPN, offering h2
* ALPN, offering http/1.1
* successfully set certificate verify locations:
*   CAfile: /etc/ssl/cert.pem
  CApath: none
* TLSv1.2 (OUT), TLS handshake, Client hello (1):
* TLSv1.2 (IN), TLS handshake, Server hello (2):
* TLSv1.2 (IN), TLS handshake, Certificate (11):
* TLSv1.2 (IN), TLS handshake, Server key exchange (12):
* TLSv1.2 (IN), TLS handshake, Request CERT (13):
* TLSv1.2 (IN), TLS handshake, Server finished (14):
* TLSv1.2 (OUT), TLS handshake, Certificate (11):
* TLSv1.2 (OUT), TLS handshake, Client key exchange (16):
* TLSv1.2 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.2 (OUT), TLS handshake, Finished (20):
* TLSv1.2 (IN), TLS change cipher, Change cipher spec (1):
* TLSv1.2 (IN), TLS handshake, Finished (20):
* SSL connection using TLSv1.2 / ECDHE-ECDSA-CHACHA20-POLY1305
* ALPN, server accepted to use h2
* Server certificate:
*  subject: C=US; ST=California; L=San Francisco; O=Cloudflare, Inc.; CN=mtls-test.net
*  start date: Oct  1 00:00:00 2021 GMT
*  expire date: Sep 30 23:59:59 2022 GMT
*  subjectAltName: host "mtls_lb.mtls-test.net" matched cert's "*.mtls-test.net"
*  issuer: C=US; O=Cloudflare, Inc.; CN=Cloudflare Inc ECC CA-3
*  SSL certificate verify ok.
* Using HTTP2, server supports multi-use
* Connection state changed (HTTP/2 confirmed)
* Copying HTTP/2 data in stream buffer to connection buffer after upgrade: len=0
* Using Stream ID: 1 (easy handle 0x7fa1a080c400)
> GET /test1 HTTP/2
> Host: mtls_lb.mtls-test.net
> User-Agent: curl/7.64.1
> Accept: */*
> 
* Connection state changed (MAX_CONCURRENT_STREAMS == 256)!
< HTTP/2 403 
< date: Wed, 06 Oct 2021 07:19:48 GMT
< content-type: text/html
< cf-access-aud: 0a892e4e5d0b3433b0b6c80498e9dee9d31078a83ef0d73093a96389a1fd9824
< cf-access-domain: mtls_lb.mtls-test.net
< expect-ct: max-age=604800, report-uri="https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct"
< server: cloudflare
< cf-ray: 699d1fa11c47203d-NRT
< 
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="robots" content="noindex">
    <meta name="viewport" content="initial-scale=1, maximum-scale=1, user-scalable=no, width=device-width">
    <title>Cloudflare Access Error</title>
(中略)
* Connection #0 to host mtls_lb.mtls-test.net left intact
* Closing connection 0

10. ログの確認

10.1 アクセスログの確認【VSI】

プログラムを実行しているディレクトリに作成されるアクセスログにはアクセスしたログが記載されます。

$ tail access.log 
[2021-09-08T16:19:29.704] [INFO] access - xxx.xx.xx.x - - "GET /test1 HTTP/1.1" 200 24 "" "curl/7.64.1"

10.2 CISログの確認【クライアントCLI】

CISのログにもアクセスしたログが記載されます。
Logpullサービスを使用してログを確認します。ログはデフォルトで保持されないため、ログの保存を有効にする必要があります。ログ保存の有効化についてはこちらを参照ください。

IBM Cloudにログインし、LogpullのCLIを使用してログをプルします。

$ ibmcloud cis logpull <ドメインID> --start 2021-09-08T16:00:00+09:00 --end 2021-09-08T17:00:00+09:00
{"ClientIP":"xxx.xxx.xx.xxx","ClientRequestHost":"mtls_lb.mtls-test.net","ClientRequestMethod":"GET","ClientRequestURI":"/test1","EdgeEndTimestamp":"2021-09-08T07:19:48Z","EdgeResponseBytes":12351,"EdgeResponseStatus":403,"EdgeStartTimestamp":"2021-09-08T07:19:48Z","RayID":"xxxx"}
{"ClientIP":"xxx.xxx.xx.xxx","ClientRequestHost":"mtls_lb.mtls-test.net","ClientRequestMethod":"GET","ClientRequestURI":"/test1","EdgeEndTimestamp":"2021-09-08T07:19:29Z","EdgeResponseBytes":1396,"EdgeResponseStatus":200,"EdgeStartTimestamp":"2021-09-08T07:19:29Z","RayID":"xxxx"}

アクセスに失敗した403エラーもログに記載されていることがわかります。

11. おわりに

本記事ではクライアントとバックエンドサーバー間でmTLSを用いたセキュアな通信を実現するため、各種証明書(クライアント証明書・サーバー証明書)の発行・CISのセットアップ(Authenticated Origin Pull設定含む)・Webサーバーの準備について記載し、実際にmTLSでアクセスが成功することを確認できました。2章でも記載しましたが、実際のケースではクライアントが別の管理者となる場合、先方のクライアント証明書に署名したルート証明書をCISにアップロードし(5.2.1の手順参照)、そのクライアント証明書と秘密鍵を使ってアクセスしてきてもらえば、今回と同様mTLSでのアクセスに成功するかと思います。
本記事が少しでも参考になりましたら幸いです。