KAKEHASHI Tech Blog

カケハシのEngineer Teamによるブログです。

サーバレスでクライアント認証を実現する

こんにちは。 BIツール等担当のチームでエンジニアをしている小室です。

先日新規プロダクトのPoC立ち上げの為の設計を行った際、クライアント証明書の検証方法に関して検討した内容をまとめて行きたいと思います!

弊社ではセンシティブな個人情報を取り扱う為に、クライアント証明書を活用してセキュリティの強化を行っております。しかし、従来はAWSにクライアント証明書を扱うマネージ機能がなかった為、nginxなどのweb serverで検証する必要がありました。 今回のPoCも個人情報を扱う為クライアント証明書の導入を決定しましたが、PoCというスピード感の中では、なるべくサーバレスで完結させたいと考え検討いたしました。

クライアント証明書とは?

SSL通信をする時にサーバが正しい事を検証するのがサーバ証明書ですが、クライアント証明書はその逆で、クライアントが正しいことを証明します。 相互に証明するので、mTLS(mutual TLS)とうワードで検索するとヒットします。

クライアント証明書を検証するマネージド機能

2020年9月のAWSのポスト でmTLSに対応したことが発表されました。特徴は以下の通りです。

  • 検証してくれる項目
    • X.509構文
    • 署名の整合性
    • 有効期間
    • 証明書の名前とサブジェクトのチェーン(整合性)
  • S3に格納したtruststore.pemに記載されたCA証明書にしたがってクライアント証明書を検証する

この様にクライアント証明書の基本的な正当性確認を行ってくれます。 一方で、それ以外の失効証明書リスト、属性(発行者)などの検証は自前で行う必要がありますので、ここは後述します。

API Gateway は、証明書が失効したかどうかを検証しません。 https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/http-api-mutual-tls.html#http-api-mutual-tls-invoke

クライアント証明書の検証を設定してみる

まずは、証明書の署名を検証するマネージド部分を、AWS CDKを利用する方法で説明します。

事前準備として、truststore.pemを配置するS3バケットを作成しておいているものとします。このS3はバージョン管理をONにすることが推奨されています。

続いて、truststore.pemを作成してアップロードします。truststore.pemの中身は こちら の通り、ルートCA証明書からの証明書を列挙したものです。

ex)

cat client.crt trusted.crt > truststore.pem

ApiGateway側の設定はCDKを用いて行います。

new apigw.LambdaRestApi(app, getResourceName('api-gw'), {
    /**
     * クライアント証明書はカスタムドメインを利用した場合のみ有効のため、
     * デフォルトのエンドポイントを無効にする。
     */
    disableExecuteApiEndpoint: false,
    // --- カスタムドメインの設定 ---
    domainName: {
      domainName: API_DOMAIN,
      
      // サーバ証明書、通常のSSL設定
      certificate: cert.Certificate.fromCertificateArn(app, 'acm', '{ACMのARN}'),

      // --- クライアント証明書を利用する設定 ---
      // デフォルトの1.1だとmTLSに対応していない
      securityPolicy: apigw.SecurityPolicy.TLS_1_2,
      mtls: {
        bucket: s3.Bucket.fromBucketName(app, 's3', '{作成済みのバケット名}'),
        key: 'truststore.pem',
        version: '{truststore.pemオブジェクトのバージョン}',
      },
    },
  });

上記のCDKを実行すると、AWSコンソールでは下記のように確認できます。

反映結果

コメントにも書いていますが、デフォルトエンドポイントではクライアント証明書の検証が走らない為、明示的に無効化して https://{api-id}.execute-api.{region}.amazonaws.com からのアクセスが出来ない様に設定します。

ちなみに、デフォルトエンドポイントを無効化し忘れた場合、ドメイン詳細の部分に警告が表示されます。

警告

その他の追加検証

失効証明書リストやAttributeを検証したい場合は自分で検証する必要があります。検証方法は以下のドキュメントの通り、Authorizerを利用できます。

Lambda オーソライザーを使用して、証明書が失効しているかどうかのチェックなど、クライアントによる API の呼び出し時に追加のチェックを実行できます。 https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/http-api-mutual-tls.html

クライアント証明書をパースする

まずはAWSの公式ドキュメントに記載されている方法を確認します。

node-express アプリの場合、client-certificate-auth モジュールを使用して、PEM エンコードされた証明書を使用するクライアントのリクエストを認証できます。 https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/getting-started-client-side-ssl-authentication.html#certificate-validation

client-certificate-auth という express middleware を利用することで、パースされたクライアント証明書を取得しています。 しかしながら内部のコードをみてみると非常にシンプルで、req.connection.getPeerCertificate() から証明書を取得しているだけで、expressがreq.connectiontls.TLSSocket を差し込んでくれている様です。 今回は単純なAuthorizerで実装したいので、自前でクライアント証明書をパースしたいと思います。ライブラリは TLS周りの実装がまとまっている node-forge を利用しました。

import * as forge from 'node-forge';
import { APIGatewayRequestAuthorizerHandler, AuthResponse } from 'aws-lambda';

export const apiAuthorizer: APIGatewayRequestAuthorizerHandler = async (event, context, callback) => {
  const certString = event.requestContext.identity.clientCert?.clientCertPem;
  const parsedClientCert = forge.pki.certificateFromPem(certString);
};

属性を確認する

クライアント証明書のパースが完了したので、属性の比較ができます。

// 利用者の属性
const subjectAttributes = parsedClientCert.subject.attributes;
// 発行者の属性
const issuerAttributes = parsedClientCert.issuer.attributes;

失効証明書リスト(CRL)を検証する

発行済みの証明書を無効化するための手段として、CRLがあります。クライアント証明書にはシリアルナンバーが記載されていますが、CRLには失効したシリアルナンバーが列挙されており、クライアント証明書と同様に発行者の署名によって正当性が保証されます。今回は発行者がCRLをHTTPエンドポイントで提供している想定で進めます。

最初にCRLファイルを取得します。

import axios from 'axios';
let _crlFile: ArrayBuffer | null = null;

// CRLを取得する
export const getCRL = async (): Promise<ArrayBuffer> => {
  if (_crlFile) return _crlFile;

  const res = await axios
          .get(CRL_ENDPOINT, {
            responseType: 'arraybuffer',
          })
          .catch(e => {
            console.error('CRLの取得に失敗しました。');
            throw e;
          });

  _crlFile = toArrayBuffer(res.data as Buffer);
  return _crlFile;
};

getCRLの戻り値はArrayBufferにしたいのですが、axiosの戻り値がUnit8Arrayだった為、一旦不器用ですが手動で変換しています。

export function toArrayBuffer(buf: Buffer) {
  const ab = new ArrayBuffer(buf.length);
  const view = new Uint8Array(ab);
  for (let i = 0; i < buf.length; ++i) {
    view[i] = buf[i];
  }
  return ab;
}

続いてCRLファイルをパースします。

import * as asn1 from 'asn1js';
import * as pvutils from 'pvutils';
import CertificateRevocationList from 'pkijs/src/CertificateRevocationList';
const pkijs = require('pkijs');

export const parseCrl = async (crlFile: ArrayBuffer): Promise<number[]> => {
  if (_parsedCrl) return _parsedCrl;
  
  // CRLをパースする
  const crl = new pkijs.CertificateRevocationList({
    schema: asn1.fromBER(crlFile).result,
  }) as CertificateRevocationList;

  const revokedList = crl.revokedCertificates;
  if (!revokedList) throw new Error('CRLの内容を取得できません。');

  // リストの内容(16進数の配列)を比較しやすい様に、10進数の配列にする
  return revokedList
    .map(cert => {
      const userCertificate = cert.userCertificate;
      // 16進数のシリアルナンバーを取得
      const hexSerialNumber = pvutils.bufferToHexCodes(userCertificate.valueBlock.valueHex);
      return parseInt(code, 16); 
    })
};

あとは組み合わせて、クライアント証明書のシリアルナンバーがCRLに含まれていないことを検証します。

import * as forge from 'node-forge';
import { APIGatewayRequestAuthorizerHandler, AuthResponse } from 'aws-lambda';

export const apiAuthorizer: APIGatewayRequestAuthorizerHandler = async (event, context, callback) => {
  const certString = event.requestContext.identity.clientCert?.clientCertPem;
  const parsedClientCert = forge.pki.certificateFromPem(certString);
  
  const crl = await getCrl();
  const parserdCrl = parseCrl(crl);
  if (parsedCrl.includes(parseInt(parsedClientCert.serialNumber, 16))) {
    throw new Error('クライアント証明書が失効しています。')
  }
};

まとめ

以上より、サーバーレス環境でクライアント証明書の検証機構を組み込むことができました。サーバを立てる必要がない為、運行コストを抑えながらクライアント証明書を利用したセキュリティ設定が実現できます。 課題としてはAuthorizerのキャッシュができないことと、暗号を扱う部分でコードの記述が多くAuthorizerのコードが少し読みにくくなっている部分があります。 いずれも今後運用しながら改善していければと考えております。

少しニッチな記事でしたが、最後まで読んでくださりありがとうございました。