Dev Study
← Webセキュリティの実務
レッスン 11 / 12·約 9 分·第19期 Webセキュリティの実務

PKCE は先にハッシュを約束し、後で原像を見せる

リクエストがアプリケーションに届くまで
  1. ブラウザURL入力今ここ
  2. DNS名前→IP
  3. ルータ経路選択
  4. ロードバランサ振り分け
  5. Nginx受け口
  6. アプリ処理今ここ
  7. DB保存

前回は、機密クライアントならコードだけでは交換できないことを見ました。今回は、公開クライアントでも始めた者と終える者を結び付ける方法を見ます。

まず結論

認可コードはリダイレクトでブラウザを通って運ばれます。ブラウザは、そのコードを受け取った者が要求を始めた当事者かどうかを確かめません。確かめるのはサーバの側で、PKCE ではそれを認可サーバが担います。

PKCE では、クライアントが最初に秘密の値(code_verifier)のハッシュだけを渡し、トークンと交換するときに元の値を見せます。認可サーバはハッシュし直して一致を確かめます。

パスワード照合と同じ一方向ハッシュの形ですが、SHA-256 をそのまま使ってよい理由は入力が高エントロピーの乱数であることにあり、ここがパスワードと逆です。

? 読む前に予測

S256 方式の PKCE で、認可要求に載っていた code_challenge をそのまま code_verifier としてトークン要求に入れると、認可サーバはどう応じるでしょうか。

当てることが目的ではありません。外した予測ほど記憶に残るので、直感で選んでください。

抽象原理

始めた者と終える者が同じかを、サーバが自分で確かめる

このコースの芯は、ブラウザが確かめられるのは「どこから来たか」までで、「本人が望んだか」はサーバが自分で確かめるしかない、ということです。別サーバをまたぐ認可でも同じ形が出てきます。

認可コードは、認可サーバからリダイレクトでブラウザを通ってクライアントへ運ばれます。ブラウザはコードを指定の宛先へ届けるだけで、受け取った者が要求を始めた当事者かどうかは見ていません。 PKCE は、始めた者と終える者の結び付けを、両方の要求を受ける認可サーバに確かめさせます(RFC 9700 §2.1.1 は、OIDC の nonce をクライアント側で確かめる代替を、機密クライアントに条件付きで認めています)。

PKCE はその結び付けを、ハッシュで行います。クライアントはまず秘密の値を作ります。

code_verifier = high-entropy cryptographic random STRING using the unreserved characters [A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~" from Section 2.3 of [RFC3986], with a minimum length of 43 characters and a maximum length of 128 characters.

認可要求に載せるのは、その値そのものではなくハッシュです(§4.2)。

code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))

トークン要求で初めて code_verifier を送り、認可サーバは BASE64URL-ENCODE(SHA256(ASCII(code_verifier))) == code_challenge を確かめます。一致しなければ invalid_grant を返すことが MUST です(§4.6)。

この教材では、この形を**「先にハッシュで約束し、後で原像を見せる」**と呼びます。RFC 7636 と RFC 9700 はこの言い方をしていません(両文書に commit の語は出てきません)。教材側の整理です。

PKCE で何がどの経路を通るか(S256)
クライアントcode_verifier を作り、手元に置く
認可要求(ブラウザ経由)に載るのは challenge だけ
認可サーバchallenge を保存してコードを発行
リダイレクトでコードが戻る
ブラウザ宛先へ運ぶだけ。受け取り手が始めた当事者かは見ない
トークン要求で code_verifier を初めて見せる
認可サーバSHA-256 し直して保存値と比べる
  • ・外を通るのはハッシュだけ。原像は最後の1回まで手元から出ない
  • ・一致の判定は認可サーバが自分で行う。ブラウザは関与しない
具体実物

原像でない値は、どれも通らない

127.0.0.1 に立てた自前の最小認可サーバに、client_secret を持たない公開クライアントを登録しました。サーバは受け取った code_verifier を node の SHA-256 で base64url にし、保存した challenge と比べます。クライアント側は python3 で乱数の verifier を作り、challenge を python3 と openssl の2通りで計算して一致を確かめてから送りました。4つのケースそれぞれで別のコードを取っています。

l11-run.sh の出力(run 1。末尾の認可サーバのログは下に分けた)コマンド出力
verifier (len 43) = IqKQd1Ut1XJskVXLB4HGZbRL01MEVVQFafNFWGEbID4
challenge python3 = V4EqOGnfd01vzu70OLZBJHzktoKJqtawqUJOcUdTVGs
challenge openssl = V4EqOGnfd01vzu70OLZBJHzktoKJqtawqUJOcUdTVGs
### [a] code#1 + a different verifier
{"error":"invalid_grant","error_description":"PKCE verification failed"}
HTTP 400
### [b] code#2 + no verifier
{"error":"invalid_grant","error_description":"PKCE verification failed"}
HTTP 400
### [c] code#3 + the challenge itself sent as verifier
{"error":"invalid_grant","error_description":"PKCE verification failed"}
HTTP 400
### [d] code#4 + the right verifier
{"access_token":"fkiqNvI2p01c6vB2SUusLk5cUeI4984a","token_type":"Bearer","expires_in":300}
HTTP 200

注目は [c] です。challenge は認可要求に載っていた値なので、外で見られうる値です。それをそのまま verifier として送っても HTTP 400 でした。認可サーバのログを見ると理由が分かります。

同じ run の認可サーバのログから [c] と [d] の比較行(challenge の行は抜粋)追跡ログ
[AS] token: PKCE stored challenge=V4EqOGnfd01vzu70OLZBJHzktoKJqtawqUJOcUdTVGs S256(received verifier)=T4zyuDeZAmJGPLF7eJlGEK5lzHk8whUxhuQDKzPROWo match=false
[AS] token: PKCE stored challenge=V4EqOGnfd01vzu70OLZBJHzktoKJqtawqUJOcUdTVGs S256(received verifier)=V4EqOGnfd01vzu70OLZBJHzktoKJqtawqUJOcUdTVGs match=true

サーバは受け取った値を必ずハッシュしてから比べます。challenge をハッシュすると T4zy… という別の値になり、一致しません。一致したのは原像を送った [d] だけです。run 2 でも値だけが違い、結果は同じでした。

この実測は自前サーバでのものです。実在の認可サーバが、失敗したコードをその場で使えなくするかどうかは確かめていません。

✓ ここまでの確認

PKCE を使うとき、認可コードだけを手にした者がトークンに交換できないのはなぜですか。

抽象原理

守りを支えているのは、関数ではなく入力の側

ハッシュが一方向でも、原像の候補を片端から試せば見つかる可能性はあります。RFC 7636 §7.1 は、前提をこう書きます。

The security model relies on the fact that the code verifier is not learned or guessed by the attacker. It is vitally important to adhere to this principle.

そのため §4.1 の NOTE は、32オクテットの乱数を base64url にして43文字の verifier を作ることを推奨し、§7.1 は a minimum of 256 bits of entropy を SHOULD とします(32オクテットは256ビット。base64url にすると43文字で、下限の43と一致します)。

§7.3 は、ソルトを使わない理由も同じところに置きます。

To reduce implementation complexity, salting is not used in the production of the code challenge, as the code verifier contains sufficient entropy to prevent brute-force attacks.

そして次の段落で、RFC 自身がパスワードとの類比と相違を書いています。

While the "S256" transformation is like hashing a password, there are important differences. Passwords tend to be relatively low-entropy words that can be hashed offline and the hash looked up in a dictionary.

約束が約束として働くのは、約束の中身を当てられないときだけです。RFC 9700 §2.1.1 はさらに、PKCE の challenge は MUST be transaction-specific だとし、使い回しを許しません。

具体実物

パスワード照合も、同じ形をしている

別コース『セキュリティとAIの仕組み』の「なぜハッシュは戻せないのか」で見たパスワード照合も、形は同じです。サーバはハッシュだけを持ち、ログインのときに利用者が原像を見せ、サーバがハッシュし直して比べます。

違うのは入力です。パスワードは人が覚える語で、エントロピーが低くなりがちです。だから NIST SP 800-63B-4 は保存を resistant to offline attacks な形にし、ソルトを付けることを SHALL で求めます。汎用ハッシュ(SHA-256 等)が速すぎてパスワード保存に不向きとされるのもこのためです。

PKCE の verifier は、RFC の推奨どおりに作れば256ビット級の乱数で、1回の認可要求にしか使いません。だから同じ SHA-256 をソルト無しで使えます。

✓ ここまでの確認

PKCE が SHA-256 をソルト無しでそのまま使ってよいのに、パスワード保存には汎用ハッシュが不向きとされるのはなぜですか。

⇄ 2つを見比べる
具体例 A

PKCE(S256)

challenge は認可要求に載り、外で見られうる。原像は、RFC の推奨どおりなら32オクテットの乱数から作る43文字の文字列で、1回の認可要求にしか使わない。ソルトを使わない理由を、RFC 7636 §7.3 はこの入力のエントロピーに置く。

何に頼っているか表
外に出るもの challenge(ハッシュ)
原像 256ビット級の乱数
寿命 認可要求1回
ハッシュ関数 SHA-256、ソルト無し
具体例 B

パスワード照合

保存したハッシュが漏れれば、攻撃者は手元で好きなだけ試せる。原像は人が覚える語で、辞書で当たりやすい。だからソルトとオフライン攻撃に耐える方式が SHALL で求められる。

何に頼っているか表
外に出うるもの 保存したハッシュ(漏洩時)
原像 人が覚える語
寿命 長く使い回される
ハッシュ関数 ソルト付きのパスワード専用方式

同じ「ハッシュを持ち、原像を見せさせる」形なのに、使う方式が逆になるのはなぜでしょうか。

関数の強さではなく、ハッシュを見た者が原像を当てられるかを両方で考えてください。

この章のまとめ

  • 認可コードを運ぶブラウザは、受け取った者が始めた当事者かを確かめない。PKCE では認可サーバが結び付けを確かめる。
  • PKCE は、認可要求に challenge(ハッシュ) だけを載せ、トークン要求で verifier(原像) を見せる。不一致は invalid_grant が MUST。
  • 実測では、別の verifier・verifier 無し・challenge そのものの3通りがすべて 400。通ったのは原像だけ。
  • 守りの前提は、verifier が 知られず当てられないこと(§7.1)。32オクテットの乱数で43文字、使うのは認可要求1回。
  • パスワード照合と同じ形だが、入力のエントロピーが逆。だから PKCE は SHA-256 をソルト無しで使える。どちら向きにも一般化しない。
  • RFC 9700 は公開クライアントに MUST、機密クライアントに RECOMMENDED。CSRF の防止は副次効果。
  • 「約束して開示する」は教材側の整理で、RFC の語ではない。

自分の現場で確かめる

読んだだけでは身につきません。手元の環境で実際に見てみてください。

環境:

理解度チェック

読んだ直後は「分かった」と感じやすいものです。実際に解いて確かめてください。

問題 1 / 3正解: 0

RFC 9700(OAuth 2.0 Security BCP)§2.1.1 は、認可コードグラントでの PKCE をどう位置付けていますか。

用語集(4語)
code_verifier
クライアントが認可要求ごとに作る秘密の乱数文字列。RFC 7636 §4.1 は43〜128文字とし、32オクテットの乱数を base64url にする作り方を推奨する。トークン要求のときに初めて送る。
code_challenge
code_verifier から作る値。S256 方式では BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))。認可要求に載せ、認可サーバが保存する。
公開クライアント
資格情報の秘密を保てないクライアント(RFC 6749 §2.1)。例として端末にインストールしたネイティブアプリや、ブラウザで動くアプリが挙がる。RFC 9700 は PKCE を MUST とする。
原像
ハッシュ関数に入れるとその値になる入力。一方向ハッシュでは、出力から原像を求めることが現実的にできない。