🤔

PKCE なにそれ?おいしいの?

2024/02/11に公開

TL;DR

  • PKCE(RFC7636)の解説をし、Goでサンプル実装します

はじめに

どうも、アニメマスターです!

皆さん、PKCEって聞いたことありますか?
ピクシー(pixy)と発音するらしいです。

ピクシーと聞いて以下のポケットなモンスターを想像した方、ご安心ください。
わたしもその一人です。
02-pixy

概観

01-architecture
OAuth 2.0のアクセストークンを発行するフローは前回の記事で確認できました。
しかし、このフローにはアクセストークンを盗まれる可能性が存在しています。
認可エンドポイントで発行した認可コードをなんらかの方法で盗聴された場合、誰でもその認可コードを悪用してアクセストークンを発行することができてしまいます。
その攻撃に対する対策がPKCE(Proof Key for Code Exchange)と呼ばれる手法です。
PKCEで登場する用語を以下に解説します。

パラメータ 説明
code_verifier 43文字~128文字以内の半角英数字と[-._~]で生成されるもの
code_challenge_method plainS256 を指定
code_challenge code_challenge_methodがplainの場合はcode_verifierと同じ。S256の場合はcode_verifierをSHA-256によりハッシュ化したもの

これらのパラメータでいかに不正にアクセストークンを発行される攻撃を防ぐかを解説します。

03-block-by-pkce

上図のようにクライアントはあらかじめcode_verifierとcode_challengeのペアを生成しておきます。
そして、認可コードを発行する時にはcode_challengeを、アクセストークンを発行する時にはcode_verifierを送ることによって、仮に悪意のあるアプリに認可コードを盗聴されたとしてもハッシュ化されたcode_challengeではcode_verifierがわからないので、アクセストークンを発行されることはなくなります。

それでは以下にGoでの実装を確認していきましょう。

ソースコードはこちらです。

https://github.com/miyuki-starmiya/go-oauth2-server

1. code_verifier, code_challengeの生成

まずはクライアント側でcode_verifierとそれをSHA-256ハッシュ化したcode_challengeを発行しておきます。

auth/util/pkce.go
package util

import (
	"crypto/sha256"
	"encoding/base64"
	"math/rand"
	"time"

	"github.com/miyuki-starmiya/go-oauth2-server/db/constants"
)

func GenerateCodeVerifier() string {
	const length = 43
	const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~"
	// init random seed
	rand.Seed(time.Now().UnixNano())
	// create a random string
	b := make([]byte, length)
	for i := range b {
		b[i] = charset[rand.Intn(len(charset))]
	}

	return string(b)
}

func GenerateCodeChallenge(codeVerifier string, codeChallengeMethod constants.CodeChallengeMethod) string {
	if codeChallengeMethod == constants.CodeChallengePlain {
		return codeVerifier
	}

	// Hash the code verifier using SHA-256
	h := sha256.New()
	h.Write([]byte(codeVerifier))
	hashed := h.Sum(nil)

	// Base64-url-encode the hash and remove any padding
	codeChallenge := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(hashed)

	return codeChallenge
}

GenerateCodeVerifierで43文字の文字列の乱数を生成しており、GenerateCodeChallengeではCodeChallengeMethod = 'plain'ならそのままcode_verifierを返し、'S256'ならSHA-256でハッシュ化したものを返しています。

また、CodeChallengeMethodは定数で決まっているので、別途定義しています。

db/constants/const.go
package constants

type CodeChallengeMethod string

const (
	CodeChallengePlain CodeChallengeMethod = "plain"
	CodeChallengeS256  CodeChallengeMethod = "S256"
)

これを認可エンドポイント(/authorize)にリクエストする前にクライアント側で発行しておきます。

2. 認可エンドポイント

次に認可エンドポイント(/authorize)で先ほど生成したcode_challengeと、付随するcode_challenge_methodをチェックしていきます。

PKCEにまつわるリクエストヘッダの検証

auth/handler/authorizeHandler.go
func validatePKCEAuthorizeRequest(r *http.Request) bool {
	CodeChallenge := r.URL.Query().Get("code_challenge")
	if CodeChallenge == "" {
		log.Println("code_challenge is empty")
		return false
	} else if CodeChallenge != "" && (len(CodeChallenge) < 43 || len(CodeChallenge) > 128) {
		log.Println("code_challenge is wrong")
		return false
	}

	codeChallengeMethod := r.URL.Query().Get("code_challenge_method")
	if codeChallengeMethod == "" {
		log.Println("code_challenge_method is empty")
		return false
	} else if constants.CodeChallengeMethod(codeChallengeMethod) != constants.CodeChallengePlain && constants.CodeChallengeMethod(codeChallengeMethod) != constants.CodeChallengeS256 {
		log.Println("code_challenge_method is wrong")
		return false
	}

	return true
}

code_challengeが決まった文字数以内か、code_challenge_methodが規定の定数かを確認しています。

3. トークンエンドポイント

最期に、トークンエンドポイント(/token)でもPKCEのリクエストを検証していきます。

PKCEにまつわるリクエストボディの検証

auth/handler/tokenHandler.go
func validatePKCETokenRequest(tr *TokenRequest, ad *model.AuthorizationData) bool {
	log.Println("got authorizationData:", ad)
	if ad.CodeChallenge == nil && ad.CodeChallengeMethod == nil {
		return true
	}

	if *tr.CodeVerifier == "" {
		log.Println("code_verifier is empty")
		return false
	} else if *tr.CodeVerifier != "" && util.GenerateCodeChallenge(*tr.CodeVerifier, *ad.CodeChallengeMethod) != *ad.CodeChallenge {
		log.Println("code_verifier is wrong")
		return false
	}

	return true
}

クライアントからcode_verifierを受け取って、それをcode_challenge_methodでハッシュ化したものと同値であるかを検証しています。
同値であれば成功で、アクセストークンを発行します。

おわりに

セキュリティにおいて攻撃と防御はいたちごっこで、新たな攻撃があるところには新たな防御機構があるものだなと思いました。

それでは、良きアニメライフを!!

References

Discussion