一覧SD-07-2 · セッションとトークンの安全性

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 / 固定の公開試料

1
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}
64
2
KEY.public_key().verify(raw(s),(h+"."+p).encode()) print(verify(t,150)["sub"])
alice
3
changed = json.loads(raw(p)) changed["sub"] = "bob" print(verify(h+"."+encode(changed)+"."+s,150))
signature_invalid
4
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
5
print(verify(token(iss="https://other.example.test"),150)) print(verify(token(aud="profile-api"),150))
issuer_rejected
audience_rejected
6
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
7
print(verify(token(exp="200"),150)) print(verify(token(nbf=True),150))
time_type_rejected
time_type_rejected

演習完了