本文へ移動

etcd v3の認証設計

etcd v3の認証

v2の認証システムを再利用しない理由

v3プロトコルは、v2のようなRESTfulインターフェースではなく、gRPCを通信に使用します。この新しいプロトコルによって、v2の設計を見直し、改善する機会が得られました。たとえば、v3の認証は接続単位で行い、v2の低速なリクエスト単位の認証から改善されています。また、次のセクションで説明するとおり、v2の認証の意味論は、一貫性を考えるうえで実際には扱いにくい面があります。v3には、v2の認証システムの欠点を解消する、明確に定義された認証機構の説明と実装があります。

機能要件

  • リクエスト単位ではなく、接続単位の認証
    • gRPC APIに、ユーザーIDとパスワードによる認証を実装する
    • 認証ポリシーの変更後は、認証を更新しなければならない
  • v2と同じように、シンプルで使いやすい機能を提供する
    • v3は、v2のディレクトリ構造とは異なり、平坦なキー空間を提供する。権限チェックは、区間との照合で行う
  • v2の認証より強い一貫性保証を持つ

必要な主な変更

  • クライアントは、認証付きのリクエストを送信する前に、認証だけに使用する専用の接続を作成しなければならない
  • Raftコマンド(etcdserverpb.InternalRaftRequest)に、権限情報(ユーザーIDと認可時のリビジョン)を追加する
  • 各リクエストの権限チェックを、APIレイヤーではなく、状態機械のレイヤーで行う

権限メタデータの一貫性

認証のメタデータも、etcdに保存する他のデータと同様に、etcdのRaftプロトコルが制御するストレージに保存し、管理する必要があります。これは、etcdクラスター全体の可用性と一貫性を損なわないために必要です。メタデータ(権限情報など)の読み書きに、クォーラムを超える、すべてのノードの合意が必要なら、単一ノードの障害でクラスター全体が停止する可能性があります。すべてのノードの同時の合意を要求すると、利用可能なクォーラムが存在していても、メンバーのいずれかが停止していれば、通常の読み書きリクエストのチェックを完了できません。この全会一致方式は、結果的にクラスターの可用性を低下させます。一貫した順序付けから合意が得られるため、Raftのクォーラムに基づくコンセンサスで十分です。

etcd v2プロトコルの認証機構には、扱いの難しい点があります。メタデータの一貫性は上記のように動作するべきですが、実際はそうなっていません。各権限チェックは、クライアントリクエストを受信したetcdメンバーが、フォロワーであっても処理します(server/etcdserver/api/v2http/client.go)。そのため、古いメタデータに基づいてチェックする可能性があります。

メタデータが古いままになるということは、運用担当者がetcdctlを実行しても、認証構成がすぐには反映されないことを意味します。したがって、古いメタデータがいつまで有効なのかを知る方法がありません。通常、構成変更はコマンドの実行直後に反映されます。しかし、高負荷の状況などでは不整合な状態が長引き、ユーザーや開発者にとって直感に反する状況が生じることがあります。そのため、このような回避策 が必要になります。

権限の不整合は線形化されたリクエストにとって安全でない

認証状態の不整合が最も深刻になるのは、書き込み時です。運用担当者があるユーザーの書き込みを無効にしても、書き込みがキー・バリューストアに対してだけ順序付けられ、認証システムに対しては順序付けられていなければ、書き込みが成功する可能性があります。認証ストアとキー・バリューストアの両方に対する順序付けがなければ、古い権限を利用した攻撃を受けるおそれがあります。

したがって、etcdの状態機械に権限チェックのロジックを追加するべきです。各状態機械は、適用段階で自身の権限情報に基づいてリクエストをチェックするべきです。そのため、認証情報は古くなっていてはいけません。

設計と実装

認証

まず、クライアントはユーザーIDとパスワードを認証するためだけのgRPC接続を作成しなければなりません。etcdサーバーは、認証レスポンスを返します。成功時には認証トークン、失敗時にはエラーを返します。クライアントはAPIリクエストを行うとき、この認証トークンを使ってetcdに認証情報を提示できます。

認証トークンの要求に使ったクライアント接続は、通常、破棄します。その接続には新しいトークンの認証情報を設定できないためです。gRPCには、接続の作成(grpc.Dial()の呼び出し)後にRPCごとの認証情報を追加する方法がありません。したがって、接続を通じて取得したトークンを、その接続に割り当てることはできません。トークンを使用するには、新しい接続が必要です。

Authenticate() RPCの実装に関する注意事項

Authenticate() RPCは、指定されたユーザー名とパスワードに基づいて認証トークンを生成します。etcdは、Goのbcryptパッケージを使って、設定されたパスワードを保存し、入力されたパスワードを検証します。bcryptのパスワード検証は設計上、計算コストが高く、一般的なx64サーバーで約100msかかります。そのため、この検証を状態機械の適用段階で行うと、etcdクラスター全体で1秒当たり約10件のAuthenticate()リクエストしか処理できず、性能上の問題になります。

性能を確保するため、v3の認証機構はetcdのAPIレイヤーでパスワードを検証し、Raftの外部で並列化できるようにしています。ただし、これにより、検査時点と使用時点の差(TOCTOU)による権限上の問題が生じる可能性があります。

  1. クライアントAがAuthenticate()リクエストを送信する
  2. APIレイヤーがAuthenticate()のパスワード検証部分を処理する
  3. 別のクライアントBがChangePassword()リクエストを送信し、サーバーがそれを完了する
  4. 状態機械のレイヤーが、AのAuthenticate()のリビジョン番号取得部分を処理する
  5. サーバーがAに成功を返す
  6. Aは、古いパスワードで認証された状態になる

この状況を避けるため、APIレイヤーは認証ストアのリビジョン番号に基づくバージョン番号の検証を行います。パスワードの検証時に、APIレイヤーは認証ストアのリビジョン番号を保存します。パスワードの検証に成功した後、保存したリビジョン番号を最新のリビジョン番号と比較します。番号が異なれば、別の処理が認証メタデータを更新したことになるため、検証をやり直します。この仕組みにより、古いパスワードに基づく認証の成功を防げます。

APIレイヤーでのトークンの解決

Authenticate()で認証した後、クライアントは認証を使用しない場合と同様にgRPC接続を作成できます。既存の初期化処理に加え、新しく作成した接続にトークンを関連付ける必要があります。このための機能を、grpc.WithPerRPCCredentials()が提供します。

クライアントからの各認証付きリクエストにはトークンが含まれます。サーバー側では、grpc.metadata.FromIncomingContext()でトークンを取得できます。サーバーは、誰がリクエストを発行し、そのユーザーがいつ認可されたかを取得できます。この情報はAPIレイヤーによって、Raftログエントリー(etcdserverpb.InternalRaftRequest)のヘッダー(etcdserverpb.RequestHeader.Usernameとetcdserverpb.RequestHeader.AuthRevision)に設定されます。

状態機械での権限チェック

etcdserverpb.RequestHeader内の認証情報は、状態機械の適用段階でチェックされます。この段階では、認証ストアの最新リビジョンに基づいて、要求されたキーに対する権限がユーザーに付与されているかを確認します。

2種類のトークン:simpleとJWT

トークンには、simpleとJWTの2種類があります。simpleトークンは、本番での使用を想定していません。暗号署名がなく、サーバーがトークンとユーザーの対応関係を状態として追跡する必要があるため、開発時のテスト用です。本番環境では、暗号署名と検証を行うJWTトークンの使用を推奨します。実装上、JWTはステートレスです。トークン自体にユーザー名やリビジョンなどのメタデータを含められるため、サーバーはトークンとメタデータの対応関係を記憶する必要がありません。

警告

simpleトークンには、既知の問題#18437 があります。etcdサーバー内ではトークンをAPIレイヤーで解決し、simpleトークンは状態を保持します。この処理は線形化可能なチェックで保護されていないため、etcdメンバーが前の認証リクエストを処理し終える前に、次のリクエストを受信することがあります。その場合、メンバーはクライアントに"invalid auth token"エラーを返す可能性があります。この問題は、ネットワーク状態が良いノードでは通常まれですが、大きな遅延があると発生することがあります。回避策として、アプリケーション側にこのエラーを処理する再試行の仕組みを実装できます。

JWTトークンの直接設定

標準のAuthenticate() RPCの流れに加え、etcdはクライアントにJWTトークンを直接設定する方法をサポートしています。これにより、生成、検証、ローテーションを含むJWTトークンのライフサイクル全体を、アプリケーションがetcdの外部で管理できます。

用途と処理の流れ

この方法は、次の場合に役立ちます。

  • etcdとは別のトークン管理システムが、JWTトークンの生成とライフサイクルを管理する
  • アプリケーションが、外部の仕組み(環境変数や構成サービスなど)を通じて署名済みのJWTトークンを受け取る
  • トークンのライフサイクルを、etcdによる自動生成ではなく、クライアントアプリケーションだけで管理する必要がある

一般的な流れは、次のとおりです。

  1. etcdとは別の外部の発行元が、ユーザー名とその他のクレームを含む、署名付きのJWTトークンを生成する
  2. アプリケーションが署名済みトークンを受け取り、etcdクライアントに設定する
  3. クライアントが、Authenticate()を呼び出さずに、リクエストにJWTトークンを直接付けて送信する
  4. etcdサーバーが、設定された公開鍵でトークンの署名を検証し、トークン内のユーザー名に基づいてアクセスを許可する
  5. トークンの有効期限が切れる前に、アプリケーションが外部の発行元から新しいトークンを取得する
  6. アプリケーションが、更新されたトークンを使って新しいクライアントを作成する(トークンの更新にはクライアントの再作成が必要)

標準の認証との違い

標準のAuthenticate()の流れでは、次のように動作します。

  • クライアントが、ユーザー名とパスワードを指定してAuthenticate()を呼び出す
  • etcdがトークンを生成して返す
  • クライアントが、後続のリクエストにこのトークンを自動的に使用する
  • トークンの更新には、再度Authenticate()を呼び出す必要がある

JWTトークンを直接設定する場合は、次のように動作します。

  • 署名済みのJWTトークンを使ってクライアントを初期化する
  • クライアントはAuthenticate()を呼び出さない
  • すべてのリクエストで、そのトークンを直接使用する
  • 有効期限前の新しいトークンの取得と、クライアントのライフサイクル管理は、クライアントアプリケーションが担う

有効なトークンがない場合のAuthStatus

JWTトークンを自ら管理するアプリケーションをサポートするため、AuthStatus RPCは、認証が有効かどうかの確認と、現在のauthRevisionの取得ができるように設計されています。これは、トークンの有効期限が切れ、外部のトークンプロバイダーから新しい有効なトークンを取得するために最新のリビジョンが必要となるリカバリー時に重要です。

この機能がなければ、トークンの有効期限切れによって現在のauthRevisionを取得できず、新しいトークンを生成できないデッドロックに陥る可能性があります。

KVSモデルとファイルシステムモデルの違いに関する注意事項

etcd v3はKVSであり、ファイルシステムではありません。そのため、正確なキー名、または["start key", "end key")のようなキー範囲の形式で、ユーザーに権限を付与できます。これは、存在しないキーに対しても権限を付与できることを意味します。意図しない権限の付与に注意してください。ファイルシステムに似たシステム(ChubbyやZooKeeperなど)では、inodeのようなデータ構造に権限情報を含められます。そのため、スティッキービットの場合を除き、存在しないキーに権限を付与することはできません。

etcd v3のモデルでは、ファイルシステムに似たシステムとは異なり、メタデータの複数回の検索が必要です。最悪の場合の検索コストは、ユーザーに権限が付与されたキーと区間の総数になります。v3の平坦なキー空間は、各inodeが権限メタデータを含むUnixのファイルシステムモデルとはまったく異なるため、このコストは避けられません。実際には、メタデータはキャッシュの効果が得られるほど小さいため、深刻な問題にはなりません。