Envoy jwt_authn と二つの身元
目標
Envoyのjwt_authnフィルターで、ローカルJWKSのRS256トークンを検証して不正なリクエストをアップストリームの手前で遮断し、検証済みのクレームだけをヘッダーで渡したうえで、mTLSで証明されるワークロードの身元とJWTで証明されるユーザーの身元が別のものであることを、実際のリクエストで確認します。
なぜ重要なのか
メッシュを導入すると、トークンの検証をサービスごとに繰り返さず、すべてのサービスの前に立つプロキシ1か所の設定に移せます。その代わり、アップストリームはプロキシが渡したヘッダーを身元として信じることになるので、検証を飛ばすパスから偽装されたヘッダーが入り込むと、そのまま身元の偽造になります。またサービス間にmTLSがかかっているからといって、ユーザー認証が終わったわけではありません。証明書が教えてくれるのは、どのワークロードが接続したかだけで、誰の代わりの要求なのかはトークンだけが教えてくれます。このラボは、2つの身元を1つのリクエストの中で並べて見させます。
ステップ
/root/st/mesh/private.pemにRSA 2048の秘密鍵を、その公開成分だけを入れたJWK Setを/root/st/mesh/jwks.jsonに(kidmesh-k1、RS256)置き、/root/st/mesh/mint.pyでRS256トークンを発行してください(isshttps://issuer.mesh.lab、audmesh-api)。/root/st/mesh/envoy.yamlに、管理19901、リスナー18000、クラスターapp(18081)、jwt_authn(providermesh)の次にrouterを置き、envoy --mode validateで検査してください。/root/st/mesh/upstream.pyのエコーサーバーを18081で、Envoyを起動し、aliceのトークンで/api/ordersをリクエストして、/root/st/mesh/allow.txtにcode=200、path=/api/ordersを書いてください。- トークンなし・偽造・期限切れ・別のオーディエンスを送って、
/root/st/mesh/deny.txtに401・401・401・403を書き、拒否されたリクエストがアップストリームのログに残らないようにしてください。 forward_payload_header: x-jwt-payloadを追加し、アップストリームが受け取ったクレームを/root/st/mesh/claims.txtに書いてください。/publicを検証の例外として開き、そのパスでx-jwt-payloadを削除してから、/root/st/mesh/public.txtに200・401・absentを書いてください。/root/st/mesh/pki/にCA・サーバー・クライアントの証明書を作り、18000にmTLSのチェーンを追加してから、/root/st/mesh/e2e.shで2つの身元を試し、/root/st/mesh/e2e.outに7行を残してください。
参考
- Envoyは静的設定を再読み込みしません。
envoy.yamlを直したら、pkill -x envoyで停止して起動し直し、curl 127.0.0.1:19901/readyがLIVEかを確認します。 - アップストリームが何を受け取ったかは、
/root/st/mesh/upstream.logの最後の行にあります。拒否されたリクエストがそこに見えるなら、フィルターがアップストリームの手前にありません。 - よくある失敗1: ルールの
requiresを空にしたりallow_missing_or_failedを使ったりして、フィルターはあるのにトークンなしのリクエストがそのまま通ってしまうケースです。 - よくある失敗2: 公開パスを開くときに
x-jwt-payloadを削除しないケースです。アップストリームが信じるヘッダーを、誰でも付けられるようになります。
署名鍵とJWKSを作る
/root/st/mesh/private.pemにRSA 2048の秘密鍵を作り、その公開成分だけを入れたJWK Setを/root/st/mesh/jwks.jsonに置いてください(鍵は1つ: kty RSA、kid mesh-k1、alg RS256、use sig、n、e)。/root/st/mesh/mint.pyは、python3 /root/st/mesh/mint.py <sub>でRS256トークンを1行出力します。ヘッダーのkidはmesh-k1、クレームはiss https://issuer.mesh.lab、aud mesh-api、sub、iat、exp(デフォルトは5分後)です。--aud、--ttl(秒、負の値ならすでに期限切れ)、--key(別の秘密鍵)のオプションを受け取るようにしてください。
JWTはbase64url(헤더).base64url(페이로드).base64url(서명)の3つの部分で、署名の対象は前の2つをドットでつないだ文字列です(プレースホルダーはヘッダー、ペイロード、署名です)。RS256はcryptographyのkey.sign(데이터, padding.PKCS1v15(), hashes.SHA256())です(プレースホルダーは署名対象のデータです)。base64urlは、末尾の=を取り除きます。JWKのn・eは、公開鍵の数値(public_numbers())をビッグエンディアンのバイト列に変えて、同じ方式でエンコードします。JWKSは誰にでも公開されるファイルなので、秘密成分(d・p・q)が入ってはいけません。
jwt_authnの設定を検証する
/root/st/mesh/envoy.yamlを作ってください。管理は127.0.0.1:19901、リスナーは127.0.0.1:18000、クラスターappは127.0.0.1:18081です。HTTPフィルターは、envoy.filters.http.jwt_authnの次にenvoy.filters.http.routerの順です。provider名はmesh、issuerはhttps://issuer.mesh.lab、audiencesは[mesh-api]、local_jwks.filenameは/root/st/mesh/jwks.jsonで、ルールはprefix /がprovider_name: meshを要求します。envoy --mode validate -c /root/st/mesh/envoy.yamlが通る必要があります。
フィルターチェーンは順番に動きます。検証フィルターがrouterの後ろにあると、リクエストはすでにアップストリームへ出た後なので、何の意味もありません。rulesのrequiresが空だと、そのパスは検証しません。トークンがあれば検査するのではなく、そもそも見ないのです。allow_missing_or_failedは誤ったトークンも通すので、この段階では使いません。--mode validateは、ポートを開かず設定だけを読むので、起動する前にフィールド名の間違いを見つけるのに使います。
有効なトークンはアップストリームまで届く
/root/st/mesh/upstream.pyを127.0.0.1:18081で起動してください。どのGETパスでも、200と{"path": 요청 경로, "headers": {소문자 헤더 이름: 값}}のJSONを返し(プレースホルダーは要求パス、小文字のヘッダー名、値です)、同じJSONを/root/st/mesh/upstream.logに1行ずつ追記します。Envoyを/root/st/mesh/envoy.yamlで起動し、mint.py aliceのトークンでhttp://127.0.0.1:18000/api/ordersにリクエストして、/root/st/mesh/allow.txtにcode=200とpath=/api/ordersの2行を書いてください。
アップストリームは認証をしません。手前に立つEnvoyが検証したと信じて、受け取ったものをそのまま見せるだけです。そのため、アップストリームが何を受け取ったかが、そのままラボの証拠になります。サーバーとEnvoyはシェルから切り離してバックグラウンドで起動し(setsid --fork nohup ...)、Envoyは管理ポートの/readyがLIVEになるまで待ちます。トークンはAuthorization: Bearer <토큰>ヘッダーで送ります(プレースホルダーはトークンです)。
トークンがない、または偽造されていれば、アップストリームの手前で遮断する
同じ/api/ordersに4種類を送り、/root/st/mesh/deny.txtに、missing=(トークンなし)・forged=(別のRSA鍵で署名し、kidはそのままにしたトークン)・expired=(--ttl -600)・wrong_aud=(--aud billing-api)の4行で、受け取ったコードを書いてください。前の3つは401、最後は403である必要があり、拒否されたリクエストは/root/st/mesh/upstream.logに1件も残ってはいけません。
攻撃者はkidもクレームも好きに書けます。持てないのは発行者の秘密鍵ただ1つで、そのため署名検証が防御のすべてです。偽造トークンは、openssl genpkeyで鍵をもう1つ作って、mint.py --keyで発行すれば足ります。401と403が分かれる点を見てください。署名・期限が間違っていれば「誰だかわからない」、署名は合っているのにオーディエンスが違えば「誰かはわかるが、ここに来るトークンではない」です。採点ツールは、リクエストごとに目印のヘッダーを付けて送り、アップストリームのログにその目印が記録されたかまで見ます。
検証済みのクレームを下流に渡す
provider meshにforward_payload_header: x-jwt-payloadを追加し(forwardはデフォルト値のfalseのまま)、Envoyを起動し直してください。mint.py aliceのトークンでリクエストし、アップストリームが受け取ったヘッダーをデコードして、/root/st/mesh/claims.txtにsub=alice、aud=mesh-api、iss=https://issuer.mesh.lab、authorization_forwarded=noの4行を書いてください。
アップストリームがトークンを再検証しなくて済むように、Envoyは検証に成功したペイロードをヘッダーに載せます。値は、パディングなしのbase64urlでエンコードしたJSONです。forwardがfalseなら、検証後に元のトークンはリクエストから削除されます。アップストリームが受け取ったトークンを別の場所で再利用できないようにするデフォルトです。クライアントが同じ名前のヘッダーを偽装して送るとどうなるかも、一度送ってみてください。採点ツールはそれも確認します。Envoyは静的設定を再読み込みしないので、直したら停止して起動し直します。
公開パスを開き、偽装された身元は削除する
jwt_authnのルールに、prefix /publicをrequiresなしで/のルールより前に置き、ルートにもprefix /publicを別に置いて、request_headers_to_removeでx-jwt-payloadを削除してください。Envoyを起動し直した後、/root/st/mesh/public.txtに、public=(トークンなしの/public/statusのコード)・api=(トークンなしの/api/ordersのコード)・spoofed_payload=(偽のx-jwt-payloadを付けて/public/statusに送ったとき、アップストリームがそのヘッダーを受け取っていればpresent、そうでなければabsent)の3行を書いてください。期待値は200・401・absentです。
ルールは最初に一致したものが優先されます。/が先に来ると、すべてのパスがそこに一致して、公開パスができません。検証するパスではEnvoyが検証済みの値でx-jwt-payloadを上書きしますが、検証しないパスでは、誰もそのヘッダーに触れません。アップストリームがそのヘッダーを身元として信じるなら、公開パスがそのまま身元の偽造の通り道になります。ルートのrequest_headers_to_removeが、その穴をふさぎます。
ワークロードの身元とユーザーの身元を分けて見る
/root/st/mesh/pki/に、CA(ca.pem)、サーバー証明書(server.pem・server.key、SANはIP:127.0.0.1とURI:spiffe://mesh.lab/ns/shop/sa/orders)、クライアント証明書(client.pem・client.key、SANはURI:spiffe://mesh.lab/ns/shop/sa/frontend)を作ってください。18000のリスナーに、tls_inspectorとtransport_protocol: tlsのフィルターチェーンを追加します。require_client_certificate: true、trusted_caはca.pemで、そのチェーンのHCMはforward_client_cert_details: SANITIZE_SETと、set_current_client_cert_detailsのuri: trueにします。起動し直した後、/root/st/mesh/e2e.shで試し、/root/st/mesh/e2e.outに、valid=200、missing=401、forged=401、workload=spiffe://mesh.lab/ns/shop/sa/frontend(mTLSでaliceのトークンを送ったとき、アップストリームが受け取ったXFCCのURI)、user=alice(同じリクエストのx-jwt-payloadのsub)、mtls_without_jwt=401(証明書だけでトークンなし)、without_client_cert=rejected(トークンだけで証明書なし、ハンドシェイク失敗)の7行を残してください。
mTLSはどのワークロードが接続を張ったかを、JWTはどの人の代わりの要求かを証明します。証明書だけでトークンがなければ401になるはずの理由がここにあります。フロントエンドのPodだという事実が、アリスだという意味にはならないのです。TLSと平文を1つのポートで受けるには、リスナーフィルターのtls_inspectorが、接続の最初のバイトを見てチェーンを選ばせます。XFCCのデフォルトの動作はSANITIZEなので、平文接続で偽装したXFCCは削除されます。SANITIZE_SETは、mTLSのときに受け取ったものを捨てて、自分が検証した証明書の情報で新しく埋め直します。サーバー証明書にIP:127.0.0.1のSANがないと、curlがサーバーを信頼しません。openssl x509 -req ... -extfileでSANを入れます。