概要
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の扱いを書きました。
違いとしては以下です。
署名アルゴリズム
署名アルゴリズムと鍵長は以下とします。
署名アルゴリズム | 鍵長 |
---|---|
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証明書の用意
登場人物や出てくる要素のイメージは以下です。
- 認証サーバでCSRを用意
- ルート認証局にCSRへの署名を要求
- ルート認証局が署名する
- 署名したことでX.509証明書となる
- リソースサーバはJWTの署名検証の公開鍵としてX.509証明書を取得
- ルート認証局から署名検証のための公開鍵(これもまた別のX.509証明書)をダウンロード
- 認証サーバから取得したX.509証明書が不正な証明書でないか検証
ルート認証局(CA)の用意について
ルート認証局(CA)をどうするかについては
- GCPのCertificate Authority Serviceのマネージドサービスを利用する
- HashiCorp VaultのPKI(公開鍵基盤)でルート証明書・中間証明書・サーバ証明書を発行 - Carpe DiemのようなPKIを用意する
- 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 }
成果物
今回のコードはこちら