JWTの検証
署名の正しさに加え、発行者・宛先・用途・有効期間が揃って初めて受け入れられることを説明します。
説明
JWTはJSONの情報をまとめる形式です。この例ではヘッダーとペイロードにEd25519で署名したJWS形式を使います。Base64urlは符号化なので、鍵がなくても中身を読めます。
同じ試料を分解して署名対象を調べ、署名を保ったまま中身を変えた場合と、署名は正しいが宛先や時刻が違う場合を試します。受入側のアルゴリズムと用途を固定して比較します。
署名の正しさに加え、発行者・宛先・用途・有効期間が揃って初めて受け入れられることを説明します。
1JWTを三つの部分へ分解する
試料はorders-api向けで、100以上200未満の時刻に有効です。
from sd07 import *
t = token()
h,p,s = t.split(".")ヘッダー・本文・署名に分解する
print(json.loads(raw(h)))
print(json.loads(raw(p)Base64urlの本文をデコードする))
print(len(raw(s)))ヘッダーにはEdDSA、at+jwt、k1。本文にはissuer・subject・audienceと時刻があります。署名は64バイトです。秘密情報を符号化しただけでペイロードへ置くことは避けます。
2署名対象のバイト列を照合する
署名対象はBase64urlの二部分をピリオドで連結したASCII列です。
KEY.public_key().verify署名対象を暗号APIで検証する(raw(s),(h+"."+p).encode())
print(verify(t,150)用途と時刻を含めて検証する["sub"])暗号APIで署名が検証でき、受入条件も満たすためaliceです。JSONを再整形した文字列ではなく、受信した二部分そのものを検証します。
3本文だけを変更する
aliceをbobへ変更し、署名は元のまま残します。
changed = json.loads(raw(p))
changed["sub"]主体を書き換える = "bob"
print(verify(h+"."+encode(changed)変更した本文へ元の署名を付ける+"."+s,150))signature_invalidです。表示用に本文を読めても、署名検証前の値を認証や認可へ利用しません。
4ヘッダーから方式や鍵を選ばせない
alg、typ、kidのいずれかを変更します。
header = json.loads(raw(h))
for k,v in (("alg","none")署名なし方式への変更を試す,("typ","JWT"),("kid","https://other.example.test/key")):
changed_header = dict(header,**{k:v})
print(k,verify(encode(changed_header)受入側の固定条件へ照合する+"."+p+"."+s,150))すべてheader_rejectedです。この受入先はEdDSA・at+jwt・ローカルのk1だけを使い、トークン中の鍵URLを取りに行く構成ではありません。
5署名が正しくても宛先違いを拒否する
試料鍵で正しく署名した別issuerと別audienceを試します。
print(verify(token(iss=発行者だけが違う試料を署名する"https://other.example.test"),150))
print(verify(token(aud=宛先だけが違う試料を署名する"profile-api"),150))それぞれissuer_rejectedとaudience_rejectedです。署名を検証できることと、このAPI向けに発行されたことは別の条件です。
6有効期間の両端を評価する
時刻ずれの許容を0として境界を固定します。
for now in (99,100,199,200)開始・終了の直前と境界を検査する:
result = verify(t,now)
print(now,result["sub"] if isinstance(result,dict) else result)nbfの100は含み、expの200は含みません。時刻ずれを許す設計では許容幅も受入条件に含め、無制限に広げないようにします。
7時刻の型も検証する
数値に見える文字列とboolを渡します。Pythonではboolがintの派生型なので、ここでは厳密な型を検査しています。
print(verify(token(exp="200")文字列の時刻を試す,150))
print(verify(token(nbf=True)真偽値の時刻を試す,150))どちらもtime_type_rejectedです。この試料の受入契約では整数だけを認めます。数値への曖昧な変換を避け、必要なclaimの型と範囲を決めます。
まとめ
署名対象は受信したヘッダーと本文の符号化列です。
方式・鍵・用途は受入側の契約で制限します。
署名に加えissuer・audience・時刻を検証します。
操作履歴と解説を振り返れます。
Python 3.15 / 固定の公開試料
from sd07 import *
t = token()
h,p,s = t.split(".")
print(json.loads(raw(h)))
print(json.loads(raw(p)))
print(len(raw(s))){'alg': 'EdDSA', 'typ': 'at+jwt', 'kid': 'k1'}
{'iss': 'https://idp.example.test', 'sub': 'alice', 'aud': 'orders-api', 'nbf': 100, 'exp': 200, 'jti': 'a1', 'family': 1}
64KEY.public_key().verify(raw(s),(h+"."+p).encode())
print(verify(t,150)["sub"])alice
changed = json.loads(raw(p))
changed["sub"] = "bob"
print(verify(h+"."+encode(changed)+"."+s,150))signature_invalid
header = json.loads(raw(h))
for k,v in (("alg","none"),("typ","JWT"),("kid","https://other.example.test/key")):
changed_header = dict(header,**{k:v})
print(k,verify(encode(changed_header)+"."+p+"."+s,150))alg header_rejected typ header_rejected kid header_rejected
print(verify(token(iss="https://other.example.test"),150))
print(verify(token(aud="profile-api"),150))issuer_rejected audience_rejected
for now in (99,100,199,200):
result = verify(t,now)
print(now,result["sub"] if isinstance(result,dict) else result)99 not_yet_valid 100 alice 199 alice 200 expired
print(verify(token(exp="200"),150))
print(verify(token(nbf=True),150))time_type_rejected time_type_rejected
演習完了