Amazon API Gateway での相互 TLS 認証をちゃんとやる

f:id:aptpod-tetsu:20211206132700j:plain aptpod Advent Calendar 2021 の 6 日目を担当する、SRE チームの柏崎です。

弊社では、intdash を組み合わせたプロジェクトが多くあります。
とあるプロジェクトでは、車両に設置するエッジコンピュータが Amazon API Gateway を利用した API と通信する、というカスタマイズ部分があります。
先日このプロジェクトで、エッジコンピュータと Amazon API Gateway の通信に、セキュリティ強化のため相互 TLS 認証を導入することになりました。

今回は、Amazon API Gateway の相互 TLS 認証での課題を解決し、より厳格に導入する方法をご紹介します。

「相互 TLS 認証」とは

例えば、ブラウザで一般的なウェブサイトを閲覧するとき、HTTPS (HTTP+TLS) が利用されます。
TLS のレイヤーでは、サーバから提示された証明書をクライアントが検証することで、クライアントがサーバを認証しています。
この認証によって、接続先のサーバが正当なものであるかどうかの確認ができ、安心して通信を行うことができます。

TLS のデフォルトでは、 クライアントがサーバを 認証するのみですが、オプションで サーバがクライアントを 認証することもできます。

クライアントとサーバが相互に相手を認証することから、「相互 TLS 認証」「Mutual-TLS」「mTLS」などと呼ばれています。
また、オプションであるクライアントの認証を有効にしているので、単に「クライアント認証」と呼ばれることもあります。

Amazon API Gateway での相互 TLS 認証と課題

Amazon API Gateway での相互 TLS 認証は、カスタムドメイン名の設定にて、簡単に有効にすることができます。
トラストストア 1 を S3 にアップロードし、カスタムドメイン名の設定にてその S3 URI を指定するだけです。

f:id:aptpod_tech-writer:20211201145226p:plain
カスタムドメイン名の設定

ただし、ここで課題となるのは、(現時点で) Amazon API Gateway の相互 TLS 認証は、証明書が失効したかどうかの検証を行わない という点です。 2

f:id:aptpod_tech-writer:20211201145307p:plain
ドキュメントの一部

証明書は、「対応する秘密鍵が漏洩した可能性がある」「誤って発行したので取り消したい」等の理由で失効される事があります。
特に今回のようなクライアントに設定する証明書は、プライベート認証局から 10 年等の長めの有効期間で発行されることも多く、 失効させてもその間は接続できてしまう という問題がおきてしまいます。

Lambda オーソライザーでの解決

Amazon API Gateway には、Lambda オーソライザーという、Lambda 関数を利用してアクセス制御を行う機能があります。
この Lambda 関数に入力される内容には、クライアントから提示された証明書が含まれています。 3

ここでは、Lambda オーソライザーを利用して証明書の失効状態を検証する方法を示します。

1. Lambda オーソライザーを実装する

Python 3.8 にて、下記の Lambda 関数を実装します。(長ったらしいですがご勘弁を…!)

環境変数 TRUSTSTORE_URI には、Amazon API Gateway のカスタムドメイン名のトラストストアと同一の S3 URI を指定してください。
また、Lambda 関数の実行ロールには、この S3 URI を読める権限を付与してください。

CertificateValidator()allow_fetching=True を与えると、CRL または OCSP 4 を利用した証明書の失効状態が検証されるようになります。

ここでのポイントは、「lambda_handler の外で、S3 からトラストストアを読み込んでいる」という点です。
これにより、S3 との通信が Lambda 関数のコールドスタート時のみ実行されるようにしています。

import os
from urllib.parse import urlparse
import boto3
from asn1crypto import pem
import json
from certvalidator import ValidationContext, CertificateValidator

trust_roots = []

truststore_uri = os.environ.get('TRUSTSTORE_URI')

if truststore_uri is not None:
    u = urlparse(truststore_uri)
    bucket = u.netloc
    key = u.path.lstrip('/')

    truststore = boto3.client('s3').get_object(Bucket=bucket, Key=key)['Body'].read()

    for _, _, der in pem.unarmor(truststore, multiple=True):
        trust_roots.append(der)

def lambda_handler(event, context):
    print("Event: " + json.dumps(event))

    principalId = event['requestContext']['identity']['clientCert']['serialNumber']
    cert = event['requestContext']['identity']['clientCert']['clientCertPem'].encode()

    context = ValidationContext(trust_roots=trust_roots, allow_fetching=True, revocation_mode='hard-fail')
    validator = CertificateValidator(end_entity_cert=cert, validation_context=context)

    try:
        validator.validate_usage(key_usage=None)
    except Exception as e:
        print("The certificate could not be validated: " + str(e))
        return generate_policy(principalId, EFFECT_DENY)
    else:
        print("The certificate has been validated")
        return generate_policy(principalId, EFFECT_ALLOW)

EFFECT_ALLOW = 'Allow'
EFFECT_DENY = 'Deny'

def generate_policy(principalId, effect):
    return {
        'principalId': principalId,
        'policyDocument': {
            'Version': '2012-10-17',
            'Statement': [
                {
                    'Action': 'execute-api:Invoke',
                    'Effect': effect,
                    'Resource': '*'
                }
            ]
        }
    }

2. Amazon API Gateway に Lambda オーソライザーを設定する

API に Lambda オーソライザーを設定します。

ここでは、認可のキャッシュを有効にしておくと良いでしょう。
クライアントからのリクエストの都度、Lambda オーソライザーが実行されてしまうことを回避できます。

f:id:aptpod_tech-writer:20211201145419p:plain
API のオーソライザー設定

続いて、メソッドリクエストに Lambda オーソライザーを設定します。

f:id:aptpod_tech-writer:20211201145435p:plain
メソッドリクエストのオーソライザー設定

これで、設定は完了です。

確認

実際に、失効済みの証明書を利用して、Lambda オーソライザー設定前後の Amazon API Gateway からのレスポンスを確認してみました。

Lambda オーソライザーの設定前は、失効済みの証明書でも下記のように 204 が返ってきていましたが、

$ curl -i --cert cert-revoked.pem --key privkey.pem https://my-great-api.example.com/
HTTP/2 204
x-amzn-requestid: cba741e3-e2eb-4ced-8332-efae0a6f4910
x-amz-apigw-id: JpzGqG_rtjMF1jg=
content-type: application/json
date: Wed, 01 Dec 2021 04:49:58 GMT

設定後は、下記のように 403 が返るようになりました。

$ curl -i --cert cert-revoked.pem --key privkey.pem https://my-great-api.example.com/
HTTP/2 403
x-amzn-requestid: 0c4e363e-25f3-4206-8d67-665641787512
x-amzn-errortype: AccessDeniedException
x-amz-apigw-id: JpzipH4CNjMFiWw=
content-type: application/json
content-length: 82
date: Wed, 01 Dec 2021 04:52:58 GMT

{"Message":"User is not authorized to access this resource with an explicit deny"}

まとめ

今回は、Amazon API Gateway の相互 TLS 認証での、失効状態が検証されない、という課題を解決する方法を紹介しました。

実際のところ、失効はそこまで頻繁に行われるものでは無いですが、いざというときにはしっかりと検証されていなければなりません。
とても地味ですが、こういった所を突き詰めるのは楽しいですね。


  1. 認証局の証明書を連結したもの。証明書を検証する際、その証明書がトラストストアに含まれる認証局から発行されているかどうか、が検証される。

  2. HTTP API の相互 TLS 認証の設定 - Amazon API Gateway

  3. Amazon API Gateway Lambda オーソライザーへの入力 - Amazon API Gateway

  4. CRL は失効された証明書が記載されているリスト。OCSP は失効状態をオンラインで検証するプロトコル。双方、証明書内に接続先等の情報が記述されている。