JWTの署名検証をOpenSSLとGroovyでやってみる

以前調べたJWTについて、jwt.ioで検索できるライブラリを使わずに、Googleで認証した署名の検証を試してみようと思い立った。

ただ、自作のスクリプトで署名の検証を計算している記事をいくつか見かけたが、さすがにそこまでフルスクラッチでやる気にはなれず。

OpenSSLで公開鍵を作り、それで検証できないかと思い、試してみたらうまくいったのでメモ。

環境

Windows 10 Pro 64bit、Groovy 2.5.7、Git for Windows v2.24.1.2付属のGit Bash、OpenSSL 1.1.1dで確認。

RSA公開鍵の作成

e(RSA暗号の公開指数)とn(RSA暗号のモジュラス)から公開鍵を作成する方法があるか調べたところ、以下のページを見つけた。

www.openssl.org

openssl asn1parse -genconf ${入力ファイル} -out ${出力ファイル} として、ASN.1形式の入力ファイルを渡すと、DERエンコードされたRSA公開鍵が出力される。

asn1=SEQUENCE:pubkeyinfo

[pubkeyinfo]
algorithm=SEQUENCE:rsa_alg
pubkey=BITWRAP,SEQUENCE:rsapubkey

[rsa_alg]
algorithm=OID:rsaEncryption
parameter=NULL

# nとeの順序は固定、逆にすると公開鍵の内容が変わって署名検証に失敗する
[rsapubkey]
n=INTEGER:0x${nの16進数表記}
e=INTEGER:0x010001

あとは openssl rsa -pubin -inform der -in ${DERエンコードされたRSA公開鍵ファイル} -out ${出力ファイル} でPEMエンコードに変換すればいい。

スクリプト

Googleで認証して、返ってきたJWTから公開鍵情報を取得し、署名検証を行ってみる。

JWTのデコードなどにはGroovyを使用。

Groovyスクリプト

公開鍵情報を取得し、もろもろのファイルを ./output/ 配下に出力する以下のGroovyスクリプトJWT.groovy として作成。

@Grapes(
    @Grab(group='commons-codec', module='commons-codec', version='1.13')
)
import groovy.json.JsonSlurper
import org.apache.commons.codec.binary.Base64

def jsonSlurper = new JsonSlurper()

def jwt = args[0].split(java.util.regex.Pattern.quote('.'))
def base64Header = jwt[0]
def base64Payload = jwt[1]
def base64Signature = jwt[2]

// Java8のBase64やGroovyのString#decodeBase64では、URLセーフなデコードができない
// Commons CodecのBase64#decodeBase64は、うまいことやってくれる
def header = jsonSlurper.parseText(new String(Base64.decodeBase64(base64Header)))

def keys = jsonSlurper.parseText(new URL('https://www.googleapis.com/oauth2/v3/certs').text).keys
def key = keys.find { it.alg == header.alg && it.kid == header.kid }

@groovy.transform.SourceURI
def sourceURI
def outputDir = new File((sourceURI.path as File).parentFile, 'output')
outputDir.mkdirs()

new File(outputDir, 'plain.txt').write("${base64Header}.${base64Payload}", 'UTF-8')

new File(outputDir, 'pubkey.ini').write("""\
asn1=SEQUENCE:pubkeyinfo

[pubkeyinfo]
algorithm=SEQUENCE:rsa_alg
pubkey=BITWRAP,SEQUENCE:rsapubkey

[rsa_alg]
algorithm=OID:rsaEncryption
parameter=NULL

[rsapubkey]
n=INTEGER:0x${Base64.decodeBase64(key.n).encodeHex().toString().toUpperCase()}
e=INTEGER:0x${Base64.decodeBase64(key.e).encodeHex().toString().toUpperCase()}
""", 'UTF-8')

new FileOutputStream(new File(outputDir, 'sig.bin')).withCloseable {
  it.write(Base64.decodeBase64(base64Signature))
}

シェルスクリプト

先のGroovyスクリプトを呼び出し、出力されたASN.1形式のファイルから公開鍵を作成、署名検証する以下のシェルスクリプトを、Groovyスクリプトと同じディレクトリに jwt.sh として作成。

#!/bin/bash

cd $(dirname ${0})

groovy ./JWT.groovy ${1}

cd output

# RSA公開鍵の作成
echo '# create pubkey.pem'
openssl asn1parse -genconf pubkey.ini -out pubkey.der
openssl rsa -pubin -inform der -in pubkey.der -out pubkey.pem

# 公開鍵の確認
echo '# show pubkey.pem'
openssl rsa -text -noout -pubin -in pubkey.pem

# 署名検証
echo '# verify signature'
cat plain.txt | openssl dgst -verify pubkey.pem -sha256 -signature sig.bin

実行結果

Google認証時のJWTトークンを引数として jwt.sh を実行すると、以下の出力が得られた。

# create pubkey.pem
    0:d=0  hl=4 l= 290 cons: SEQUENCE
    4:d=1  hl=2 l=  13 cons: SEQUENCE
    6:d=2  hl=2 l=   9 prim: OBJECT            :rsaEncryption
   17:d=2  hl=2 l=   0 prim: NULL
   19:d=1  hl=4 l= 271 prim: BIT STRING
writing RSA key
# show pubkey.pem
RSA Public-Key: (2048 bit)
Modulus:
    ...
Exponent: 65537 (0x10001)
# verify signature
Verified OK

振り返り

実運用では当然ライブラリを使うが、手作業でやってみるとRSAにおける署名の勉強になった。

あと、自前で計算してる人たちはすごいな...