JWT(RS256/EdDSA)¶
認証 のページでは JWT の署名に HS256(共通鍵)を使った。ここでは公開鍵方式の RS256 と EdDSA を深掘りして、速度や鍵の扱いを比較する。
PyJWT で RS256 / EdDSA を使うには cryptography が必要なので、まとめてインストールしておく。
RS256¶
Auth0 の "Token Best Practices" では、 HS256 よりも RS256 が推奨されている。
cf. https://auth0.com/docs/secure/tokens/token-best-practices#signing-algorithms
RS256 は公開鍵方式なので、署名用の秘密鍵と検証用の公開鍵が必要。 openssl で作る。
openssl genpkey -algorithm RSA -out private.pem -pkeyopt rsa_keygen_bits:3072
openssl rsa -pubout -in private.pem -out public.pem
chmod 600 private.pem
秘密鍵で署名し、公開鍵で検証するコードを作成。
import jwt # PyJWT
from datetime import datetime, timedelta, timezone
# 1. 秘密鍵を読み込む
with open("private.pem", "rb") as f:
private_key = f.read()
# 2. ペイロードの準備
expire = datetime.now(timezone.utc) + timedelta(minutes=10)
to_encode = {"exp": expire, "sub": "alice"}
# 3. 署名(RS256 を指定し、秘密鍵でエンコード)
jwt_token = jwt.encode(to_encode, private_key, algorithm="RS256")
print("--- 生成された JWT ---")
print(jwt_token)
# --- 検証側 ---
# 4. 公開鍵を読み込む(検証には公開鍵しか使わない)
with open("public.pem", "rb") as f:
public_key = f.read()
# 5. デコード(公開鍵で検証)
try:
decoded_data = jwt.decode(jwt_token, public_key, algorithms=["RS256"])
print("--- デコード成功 ---")
print(decoded_data)
except jwt.ExpiredSignatureError:
print("有効期限切れです")
except jwt.InvalidTokenError:
print("トークンが不正です")
Note
HS256 は署名も検証も同じ秘密鍵を使うので、もし署名するホストと検証するホストを別にしようと思うと、秘密鍵を共有しないといけない・・・
RS256 は署名=秘密鍵・検証=公開鍵に分かれるので、検証する側には公開鍵だけ配ればいいので、署名する側と検証する側を別ホストに分けてもセキュリティを比較的保てる。
処理速度比較¶
RS256 は HS256 よりセキュリティレベルは高いけど、処理は重い。 RS256 より効率的とされる EdDSA も含めて計測してみる。
import jwt
from cryptography.hazmat.primitives.asymmetric import rsa, ed25519
payload = {"user_id": 12345, "role": "admin"}
# HS256
secret_hs = "dZGDYwX9y5GBU--lgwFcfvgxgYS_v1P_SWZSGmd8Ib8"
# RS256: RSA 鍵(3072bit)
private_key_rsa = rsa.generate_private_key(public_exponent=65537, key_size=3072)
public_key_rsa = private_key_rsa.public_key()
# EdDSA: Ed25519 鍵
private_key_ed = ed25519.Ed25519PrivateKey.generate()
public_key_ed = private_key_ed.public_key()
# 検証用トークン
token_hs = jwt.encode(payload, secret_hs, algorithm="HS256")
token_rs = jwt.encode(payload, private_key_rsa, algorithm="RS256")
token_ed = jwt.encode(payload, private_key_ed, algorithm="EdDSA")
JupyterLab の %%timeit で、 encode + decode を1回あたりに換算して計測する。
%%timeit
# HS256
jwt.encode(payload, secret_hs, algorithm="HS256")
jwt.decode(token_hs, secret_hs, algorithms=["HS256"])
%%timeit
# RS256
jwt.encode(payload, private_key_rsa, algorithm="RS256")
jwt.decode(token_rs, public_key_rsa, algorithms=["RS256"])
%%timeit
# EdDSA
jwt.encode(payload, private_key_ed, algorithm="EdDSA")
jwt.decode(token_ed, public_key_ed, algorithms=["EdDSA"])
| アルゴリズム | encode + decode 1回 |
|---|---|
| HS256 | 約 11.6 μs / loop |
| RS256 | 約 2.16 ms / loop |
| EdDSA | 約 270 μs / loop |
Info
数値は手元のマシンでの実測値で、環境によって変わる。見るべきは絶対値ではなく桁の差。
考察:
- HS256 がダントツで速い。ただし共通鍵方式なので、成功するかは別として、秘密鍵を総当たりするオフライン攻撃の標的にはなりうる。
- RS256 が一番遅い。 HS256 と比べて桁が違う(μs → ms)。
- EdDSA は RS256 より10倍くらい速い。
Auth0 が RS256 を推奨しているのは、おそらく互換性を重視しているからか。セキュアに公開鍵と秘密鍵を別ホストに置くような使い方では両者が対応している必要があって、 RS256 は古株で広く対応してるので。遅いとはいえ最近のチップの処理能力なら問題にならない、っていうことなのかもしれない。
一方、公開鍵も秘密鍵も同じホストに置いて署名・検証を1か所でやるなら、 RS256 より EdDSA のほうが明らかに有利。
EdDSA (Ed25519)¶
PyJWT は EdDSA も使える。
cf. https://pyjwt.readthedocs.io/en/stable/algorithms.html
EdDSA - Both Ed25519 signature using SHA-512 and Ed448 signature using SHA-3 are supported. Ed25519 and Ed448 provide 128-bit and 224-bit security respectively.
Ed25519 は 256ビットの鍵を使う(RFC 8032 のパラメータ b)。
鍵長とセキュリティ強度
Ed25519 の 256ビット鍵は 128ビットセキュリティと等価。 RSA で同じ 128ビットセキュリティを得るには 3072ビットの鍵が必要。
鍵の長さは JWT の署名部分のサイズに影響するので、短くて強い鍵のほうがいい。
cf. NIST SP 800-57 Part 1 Rev.5 (p.54~)
EdDSA(Ed25519)を使うときも、秘密鍵と公開鍵を作っておく。
cf. https://pyjwt.readthedocs.io/en/stable/usage.html#encoding-decoding-tokens-with-eddsa-ed25519
# generate_keys.py
from cryptography.hazmat.primitives.asymmetric import ed25519
from cryptography.hazmat.primitives import serialization
def generate_eddsa_key_pair():
"""EdDSA (Ed25519) の鍵ペアを生成して PEM で保存する。"""
private_key = ed25519.Ed25519PrivateKey.generate()
public_key = private_key.public_key()
# 秘密鍵(PKCS8)
with open("eddsa_private.pem", "wb") as f:
f.write(private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
))
# 公開鍵(SubjectPublicKeyInfo)
with open("eddsa_public.pem", "wb") as f:
f.write(public_key.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
))
if __name__ == "__main__":
generate_eddsa_key_pair()
鍵ができたら、秘密鍵のパーミッションを 600 にしておく。 RSA(3072bit) と違って鍵がとても短いのも分かる。
% ls -l | grep eddsa_p
-rw-------@ 1 alice staff 119 3 03 08:47 eddsa_private.pem
-rw-r--r--@ 1 alice staff 113 3 03 08:47 eddsa_public.pem
% cat eddsa_public.pem
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEARTLNyu9oUpd7UDMSZpoO/0ZpgSpzny8hJd1DM9mXKKE=
-----END PUBLIC KEY-----
cryptography.hazmat.primitives.serialization で読み込んで使う。
import jwt
from cryptography.hazmat.primitives import serialization
from datetime import datetime, timedelta, timezone
# 鍵の読み込み
with open("eddsa_private.pem", "rb") as f:
private_key = serialization.load_pem_private_key(f.read(), password=None)
with open("eddsa_public.pem", "rb") as f:
public_key = serialization.load_pem_public_key(f.read())
# JWT 生成
payload = {"sub": "alice", "exp": datetime.now(timezone.utc) + timedelta(minutes=5)}
token = jwt.encode(payload, private_key, algorithm="EdDSA")
print("JWT:", token)
# JWT 検証
decoded = jwt.decode(token, public_key, algorithms=["EdDSA"])
print("Decoded:", decoded)
JWT: eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbGljZSIsImV4cCI6MTc4MDM4MTEyMH0.2xR...(省略)
Decoded: {'sub': 'alice', 'exp': 1780381120}
公開鍵は秘密鍵から導出できる¶
上のコードは公開鍵ファイルも作っているけど、秘密鍵さえあれば公開鍵は導出できるので、ファイルは秘密鍵だけ用意すればいい。
# generate_key.py
from cryptography.hazmat.primitives.asymmetric import ed25519
from cryptography.hazmat.primitives import serialization
def generate_eddsa_private_key():
"""EdDSA (Ed25519) の秘密鍵だけを生成して PEM で保存する。"""
private_key = ed25519.Ed25519PrivateKey.generate()
with open("eddsa_private.pem", "wb") as f:
f.write(private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
))
if __name__ == "__main__":
generate_eddsa_private_key()
読み込みも秘密鍵だけになって、公開鍵は private_key.public_key() で導出すればいい。
import jwt
from cryptography.hazmat.primitives import serialization
from datetime import datetime, timedelta, timezone
# 鍵の読み込み(秘密鍵だけ)
with open("eddsa_private.pem", "rb") as f:
private_key = serialization.load_pem_private_key(f.read(), password=None)
public_key = private_key.public_key() # 秘密鍵から導出
payload = {"sub": "alice", "exp": datetime.now(timezone.utc) + timedelta(minutes=5)}
token = jwt.encode(payload, private_key, algorithm="EdDSA")
decoded = jwt.decode(token, public_key, algorithms=["EdDSA"])
print("Decoded:", decoded)
公開鍵を「ファイルから読む」場合と「秘密鍵から導出する」場合で、準備にかかる時間を %%timeit で比較する。
%%timeit
with open("eddsa_private.pem", "rb") as f:
private_key = serialization.load_pem_private_key(f.read(), password=None)
with open("eddsa_public.pem", "rb") as f:
public_key = serialization.load_pem_public_key(f.read())
%%timeit
with open("eddsa_private.pem", "rb") as f:
private_key = serialization.load_pem_private_key(f.read(), password=None)
public_key = private_key.public_key()
| 公開鍵の準備 | 時間 |
|---|---|
| ファイルから読み込み | 約 104 μs / loop |
| 秘密鍵から導出 | 約 92 μs / loop |
そんなに変わらないけど、ファイルが少なくて済むし、少し速いので、秘密鍵から導出する書き方でよさそう。
鍵ファイルのキャッシュ¶
プロパティのキャッシュ(cached_property) を使えば、読み込んだ鍵をキャッシュして毎回ファイルを開かずに済む。
cf. https://docs.python.org/ja/3/library/functools.html#functools.cached_property
まずは cached_property を使わない場合。 public_key() が内部で private_key() を呼ぶので、1回の処理でファイルを2回開いている。
import jwt
from cryptography.hazmat.primitives import serialization
from datetime import datetime, timedelta, timezone
class NoCacheBench:
def __init__(self, priv_path):
self.priv_path = priv_path
self.payload = {"sub": "alice", "exp": datetime.now(timezone.utc) + timedelta(minutes=5)}
def private_key(self):
with open(self.priv_path, "rb") as f:
return serialization.load_pem_private_key(f.read(), password=None)
def public_key(self):
# 内部で private_key() を呼ぶ(ここでまたファイルを開く)
return self.private_key().public_key()
bench_no_cache = NoCacheBench("eddsa_private.pem")
%%timeit
token = jwt.encode(bench_no_cache.payload, bench_no_cache.private_key(), algorithm="EdDSA")
decoded = jwt.decode(token, bench_no_cache.public_key(), algorithms=["EdDSA"])
cached_property にすると、初回だけファイルを読み込んで、2回目以降はキャッシュを返す。
import jwt
from cryptography.hazmat.primitives import serialization
from datetime import datetime, timedelta, timezone
from functools import cached_property
class CachedBench:
def __init__(self, priv_path):
self.priv_path = priv_path
self.payload = {"sub": "alice", "exp": datetime.now(timezone.utc) + timedelta(minutes=5)}
@cached_property
def private_key(self):
with open(self.priv_path, "rb") as f:
return serialization.load_pem_private_key(f.read(), password=None)
@cached_property
def public_key(self):
# self.private_key を参照(キャッシュ済みなら一瞬)
return self.private_key.public_key()
bench_cached = CachedBench("eddsa_private.pem")
%%timeit
token = jwt.encode(bench_cached.payload, bench_cached.private_key, algorithm="EdDSA")
decoded = jwt.decode(token, bench_cached.public_key, algorithms=["EdDSA"])
| encode + decode 1回 | |
|---|---|
cached_property なし |
約 463 μs / loop |
cached_property あり |
約 275 μs / loop |
ファイルI/Oが減るぶん、 cached_property ありのほうが速い。鍵はアプリ起動中ずっと同じなので、キャッシュしておくのが良さそう。
Note
@cached_property を付けると、メソッドではなく「プロパティ」になる点に注意(呼び出しが obj.private_key() から obj.private_key に変わる)。