Carpe Diem

備忘録

JWTの署名検証で使う公開鍵をX.509証明書で管理する

概要

JWTをアクセストークンとして利用する場合、署名(秘密鍵)は認証サーバで、署名検証(公開鍵)はリソースサーバで行うのが良いです。

そのため認証サーバは公開鍵をリソースサーバに公開する必要があります。

Googleなどの大規模サービスを見ると、生の公開鍵を公開しているのではなくX.509証明書の形で公開されています。
これは

  • 公開鍵の有効期間が設定できる
  • 公開鍵が改ざんされていない事が分かる
  • なりすましによる公開鍵でないことが分かる
  • 秘密鍵が漏洩した時に失効ができる

といったデジタル署名のメリットを享受できるようにと考えられます。

{
  "4e00e8fe5f2c88cf0c7044f307f7e739788e4f1e": "-----BEGIN CERTIFICATE-----\nMIIDHDCCAgSgAwIBAgIILnkHftPtFMYwDQYJKoZIhvcNAQEFBQAwMTEvMC0GA1UE\nAxMmc2VjdXJldG9rZW4uc3lzdGVtLmdzZXJ2aWNlYWNjb3VudC5jb20wHhcNMjEw\nMzEzMDkyMDE2WhcNMjEwMzI5MjEzNTE2WjAxMS8wLQYDVQQDEyZzZWN1cmV0b2tl\nbi5zeXN0ZW0uZ3NlcnZpY2VhY2NvdW50LmNvbTCCASIwDQYJKoZIhvcNAQEBBQAD\nggEPADCCAQoCggEBAKhhzpJd8Eeu/lCdaA0x2P0gHvVT3aGmH4bxgbVpLjvQDZEr\nDebKhDaMNJDx16MDDJWo7oFzSCLe8humbCKqRymRISD7S2BsUnYBSgShrhkFZ00S\nFxan9znx8sev4sIWaxy0M7FEUVLpKlzcBIVpK5Wpj1P8z1A0lVhd5lj/gHY/WTLv\nNyG1tcajR0nSSygymGlYpfJKWuBjJTQSbhfTujXFaqUGKov6OeRU+7jBTow4M8Cu\nk8a1eohl/2ti5MHjPYtXvhahfDo33uHZ9TTle5NEFZCC2lW8wL4RBvCqhhw2i5EV\nfDuqSw9/G2MHCN5QmFTwNY+LNE2aghLY7kIvQW8CAwEAAaM4MDYwDAYDVR0TAQH/\nBAIwADAOBgNVHQ8BAf8EBAMCB4AwFgYDVR0lAQH/BAwwCgYIKwYBBQUHAwIwDQYJ\nKoZIhvcNAQEFBQADggEBAAI6cD9Tx1QSwdUj1k+jkLBor9m6mZHv2cb40jsjiFxo\nYZESNCpLikD7K6pRewbFIrvqnUQDMxNVMlrFCWVm7NqJPJKvdnWEVOHqW5TgoMe3\nHnkzgpjEwREQfWJGJeW6yQbg0t8NW4h8Wi516aL3uNP8pgR7ZSLBwzbnW24SS1Kc\nfOMVLrKYaugvpDHy7TeK4WEilnGV3l2Agwq4cmqqdZscrRKxfrq9leLeNDpFlPLK\nlqrRlsuf5Zo0nOEsQ/+XW0vEsR+3VEGAgKJ4vRJoXsTh2DDp7StfwxXbQNBZ8MdR\nZ65pwGB7HGCXZiCrAo2ZyMGYr91SRgdlXzjRtOiYcX4=\n-----END CERTIFICATE-----\n",
  "48949d7d407fec9b2ac8d635ecba0b7a914ed8fb": "-----BEGIN CERTIFICATE-----\nMIIDHDCCAgSgAwIBAgIIROeL9MsgVpEwDQYJKoZIhvcNAQEFBQAwMTEvMC0GA1UE\nAxMmc2VjdXJldG9rZW4uc3lzdGVtLmdzZXJ2aWNlYWNjb3VudC5jb20wHhcNMjEw\nMzA1MDkyMDE2WhcNMjEwMzIxMjEzNTE2WjAxMS8wLQYDVQQDEyZzZWN1cmV0b2tl\nbi5zeXN0ZW0uZ3NlcnZpY2VhY2NvdW50LmNvbTCCASIwDQYJKoZIhvcNAQEBBQAD\nggEPADCCAQoCggEBAME/0KmvljavWngX6QNm9PeJsSo3QUkoobUvFOBjFkDnDR0F\npa9bv5dQxpMr+TFvSvMIBLKENde1hD9q8dgN9WXpa++XxEKbdAx86nhjVLYzDm0O\nsMyonvddF0kPvTOKD2SGof2uoceeDu6AJnymM0EO5lpj15Bz8FThm9NDOM1mElA+\nmuhNAdgyIjVNRSDyFSE177DqoHJ7hVYsHW1Pvyrfkh0CtHV73XQsxT4xJdXGPJxf\nC3g0NsUVRMpcJzqMZm42QTwdXIIsQE7GWRZVzcNayyccHu7MEafH5zNaQYpUHnio\nj2KZJ1AxTERECoUa6VGLWttTVW34YKqK5Dv7qQMCAwEAAaM4MDYwDAYDVR0TAQH/\nBAIwADAOBgNVHQ8BAf8EBAMCB4AwFgYDVR0lAQH/BAwwCgYIKwYBBQUHAwIwDQYJ\nKoZIhvcNAQEFBQADggEBALDDPZJiCZiCF5kq3C0jKrZeg0TXNzsnnicbXxQMhefz\nQeRfD3M0wnuTqLtCYgqSs3jWUZTMkXP3vaYnDbr+Wk1uHzIuX4GLzqqNrkJzVAfm\nC2ashKj+4PwcSPa9hfRXh/GAgtCOE9FJOOD0bRQKnSowSY3B8ZftkGpMOD//hYJK\nIFl05d9Cu2rCkdg6Trgx1GrpszSHU0WP2TFRZApuWpAogPpAPBMIDaEaZ8YYgMed\nBJkDGaJaHP6wwD1WISYJ8JownG5rHLEGsCrPAUzFaURT+z1LkuE4flCIxErl8gO9\ndTw/jtB1GXqbU/O8/d0uHiEsL6Kp9b/JgINIh730dK8=\n-----END CERTIFICATE-----\n"
}

ref: https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com

今回はその場合の署名検証の実装を説明します。

環境

  • Go 1.16.2
  • jwt-go v3.2.0
  • OpenSSL 1.1.1

以前との差分

以前もJWTの扱いを書きました。

christina04.hatenablog.com

違いとしては以下です。

RFCでもES256が最も推奨されています

署名アルゴリズム

署名アルゴリズムと鍵長は以下とします。

署名アルゴリズム 鍵長
ECDSA(prime256v1) 256bit

曲線パラメータは多数あり、

$ openssl ecparam -list_curves
  secp112r1 : SECG/WTLS curve over a 112 bit prime field
  secp112r2 : SECG curve over a 112 bit prime field
  secp128r1 : SECG curve over a 128 bit prime field
  secp128r2 : SECG curve over a 128 bit prime field
  ...

Bernstein and LangeのレポートでFalseになる曲線は心配になるかもしれませんが、こちらを読む感じprime256v1であれば問題ありません

X.509証明書の用意

登場人物や出てくる要素のイメージは以下です。

  1. 認証サーバでCSRを用意
  2. ルート認証局CSRへの署名を要求
  3. ルート認証局が署名する
  4. 署名したことでX.509証明書となる
  5. リソースサーバはJWTの署名検証の公開鍵としてX.509証明書を取得
  6. ルート認証局から署名検証のための公開鍵(これもまた別のX.509証明書)をダウンロード
  7. 認証サーバから取得したX.509証明書が不正な証明書でないか検証

ルート認証局(CA)の用意について

ルート認証局(CA)をどうするかについては

  1. GCPのCertificate Authority Serviceのマネージドサービスを利用する
  2. HashiCorp VaultのPKI(公開鍵基盤)でルート証明書・中間証明書・サーバ証明書を発行 - Carpe DiemのようなPKIを用意する
  3. OpenSSLで手動で用意する

といった選択肢があります。

一番手軽なのは3番目ですが、OpenSSLでやる場合は

  • 失効リストの管理に困る
  • ルートCAの秘密鍵の管理に困る

といったデメリットもあります。

今回は簡単のためOpenSSLで行います。

ルートCAの秘密鍵・証明書を作成

秘密鍵を生成

$ openssl genrsa -out ca.key 4096

自己(オレオレ)証明書を作成

$ openssl req -new -x509 -key ca.key \
  -days 3650 -sha256 -out ca.crt

Subject設定は以下のように入れていきます。

-----
Country Name (2 letter code) []:JP
State or Province Name (full name) []:Tokyo
Locality Name (eg, city) []:Shibuya-ku
Organization Name (eg, company) []:HogeHoge, Inc.
Organizational Unit Name (eg, section) []:
Common Name (eg, fully qualified host name) []:hogehoge.co.jp

【認証サーバ】JWTの公開鍵をX.509証明書に

認証サーバで扱うJWTの秘密鍵を用意します。

$ openssl ecparam -name prime256v1 -genkey -noout -out secret.key

証明書署名要求(CSR)を作成

$ openssl req -new -sha256 -key secret.key -out public.csr

Subject設定は以下のように入れていきます。

-----
Country Name (2 letter code) []:JP
State or Province Name (full name) []:Tokyo
Locality Name (eg, city) []:Shinagawa-ku
Organization Name (eg, company) []:FugaFuga, Inc.
Organizational Unit Name (eg, section) []:
Common Name (eg, fully qualified host name) []:fugafuga.co.jp

ルートCAの証明書と秘密鍵で署名して、認証サーバの証明書を作成

$ openssl x509 -req -in public.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
  -days 365 -sha256 -out public.crt

証明書の確認

ここまで進めると以下のような状態になっています。

$ openssl x509 -in public.crt -text -noout
Certificate:
    Data:
        Version: 1 (0x0)
        Serial Number: 10881507830942508462 (0x9702e1b833f5a5ae)
    Signature Algorithm: sha256WithRSAEncryption
        Issuer: C=JP, ST=Tokyo, L=Shibuya-ku, O=HogeHoge, Inc., CN=hogehoge.co.jp
        Validity
            Not Before: Mar 20 08:08:34 2021 GMT
            Not After : Mar 20 08:08:34 2022 GMT
        Subject: C=JP, ST=Tokyo, L=Shinagawa-ku, O=FugaFuga, Inc., CN=fugafuga.co.jp
        Subject Public Key Info:
            Public Key Algorithm: id-ecPublicKey
                Public-Key: (256 bit)
                pub:
                    04:35:b0:f8:e7:64:81:72:13:e6:e4:21:e9:84:22:
                    85:e4:cd:0a:37:83:9a:50:e5:00:89:2c:96:8e:e6:
                    19:8e:67:19:28:7e:2e:55:6a:7f:92:c2:f4:f5:4a:
                    7c:01:04:5f:fb:77:c5:7a:28:72:3c:e0:bb:48:e2:
                    43:49:be:17:12
                ASN1 OID: prime256v1
                NIST CURVE: P-256

実装

準備が整ったのでGoで実装します。

認証サーバ

まず認証サーバ側の実装です。

秘密鍵の読み込み

アルゴリズムが違いますがこちらは前回とほぼ同様です。

func initPrivateKey() (interface{}, error) {
        raw, err := ioutil.ReadFile("./secret.key")
        if err != nil {
                return nil, err
        }
        privateKeyBlock, _ := pem.Decode(raw)
        if privateKeyBlock == nil {
                return nil, errors.New("private key cannot decode")
        }
        if privateKeyBlock.Type != "EC PRIVATE KEY" {
                return nil, errors.New("private key type is not rsa")
        }
        key, err := x509.ParseECPrivateKey(privateKeyBlock.Bytes)
        if err != nil {
                return nil, errors.New("failed to parse private key")
        }

        return key, nil
}

JWT生成(署名)

生成部分はアルゴリズムの指定だけjwt.SigningMethodES256に変わってます。

func GenerateToken(userID string, now int64) (tokenString string, err error) {
        claims := jwt.StandardClaims{
                Subject:   userID,
                IssuedAt:  now,
                ExpiresAt: time.Unix(now, 0).AddDate(0, 0, expiryDays).Unix(),
        }
        token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)

        // sign by secret key
        tokenString, err = token.SignedString(secretKey)
        if err != nil {
                err = errors.Wrap(err, "Failed to sign token")
                return
        }

        return
}

リソースサーバ

次にリソースサーバ側の実装です。

ルートCAの証明書をCertPoolに

認証サーバが公開している公開鍵を検証するための認証局情報の登録です。

func initRootCerts() (*x509.CertPool, error) {
        roots := x509.NewCertPool()
        caCert, err := ioutil.ReadFile("./ca.crt")
        if err != nil {
                return nil, err
        }
        if ok := roots.AppendCertsFromPEM(caCert); !ok {
                return nil, errors.New("invalid certficate")
        }
        return roots, nil
}

公開鍵の読み込み

公開鍵の証明書をルートCAの証明書で検証してから公開鍵を取得します。

func initPublicKey(roots *x509.CertPool) (interface{}, error) {
        pCert, err := ioutil.ReadFile("./public.crt")
        if err != nil {
                return nil, err
        }

        publicKeyBlock, _ := pem.Decode(pCert)
        if publicKeyBlock == nil {
                return nil, errors.New("public key cannot decode")
        }
        if publicKeyBlock.Type != "CERTIFICATE" {
                return nil, errors.New("public key type is invalid")
        }

        cert, err := x509.ParseCertificate(publicKeyBlock.Bytes)
        if err != nil {
                return nil, errors.New("failed to parse public key")
        }
        opt := x509.VerifyOptions{
                Roots: roots,
        }
        _, err = cert.Verify(opt)
        if err != nil {
                return nil, errors.New("failed to verify certficate")
        }
        if cert.Subject.CommonName != "fugafuga.co.jp" {
                return nil, errors.New("invalid subject")
        }

        return cert.PublicKey, nil
}

ポイント

ポイントは以下です。

  • ParseCertificate()で証明書として読み込み
  • ルートCAの証明書でcert.Verify()で検証
    • 改ざんのチェック
    • 有効期限のチェック
  • SubjectのCNのチェック
    • なりすましのチェック

署名検証

署名検証も署名方法の部分のみjwt.SigningMethodECDSAに変わってます。

func VerifyToken(tokenString string) (*jwt.Token, error) {
        parsedToken, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
                // check signing method
                if _, ok := token.Method.(*jwt.SigningMethodECDSA); !ok {
                        err := errors.New("Unexpected signing method")
                        return nil, err
                }
                return publicKey, nil
        })
        if err != nil {
                err = errors.Wrap(err, "Token is invalid")
                return nil, err
        }
        if !parsedToken.Valid {
                return nil, errors.New("Token is invalid")
        }

        return parsedToken, nil
}

成果物

今回のコードはこちら

github.com