Authlibの不完全なアルゴリズムの混同を修正する機能により、プレフィックス付きの公開鍵を使用したHMACの偽造が可能になる

## 概要

`OctKey.import_key()`は、非対称公開鍵が`authlib.jose`でHMACのシークレットとして使用されるのを防ぐための唯一の保護機能です。その保護機能は`raw_key.startswith(POSSIBLE_UNSAFE_KEYS)`であり、これはバイト0から始まる文字列です。一方、非対称鍵のパスで同じバイトを処理するローダー(`cryptography.hazmat.primitives.serialization.load_pem_public_key` / `load_pem_x509_certificate`)は、`-----BEGIN`行より前の内容は無視します。したがって、**先頭に1バイト**(改行、スペース、インデントされた4つのスペース、UTF-8 BOM、またはコメント行など)を持つPEM公開鍵またはX.509証明書は、認識されず、処理をスキップされ、HMACのシークレットとして扱われます。攻撃者が発行者の公開鍵のみを持っている場合、任意のクレームを持つ`HS256`トークンを作成し、`jwt.decode()`がそれを有効なものとして受け入れてしまいます。

これは、**CVE-2024-37568 / GHSA-5357-c2jx-v7qh**の不完全な修正です。この脆弱性に関する修正は、まさにこの保護機能です(`git log -S 'POSSIBLE_UNSAFE_KEYS' -- authlib/jose/rfc7518/oct_key.py`を実行すると、コミット`3bea812`「fix: OctKeyがssh/rsa/pem鍵をインポートできない問題を修正」が返され、これはissue #654を参照しています)。元の攻撃は、保存されているPEMファイルがオフセット0から始まらない場合に、変更なしで再び機能します。

**1.8.0**(2026年8月30日にリリースされた最新版)および`origin/main`(`c529a61`)で、`cryptography` 50.0.2を使用して、エンドツーエンドで再現されました。

## 前提条件

これらはどちらもデプロイメント時のプロパティであり、リクエスト時に攻撃者によって制御されるものではありません。攻撃者は秘密情報やユーザーとのインタラクションを必要としません。

1. **依存するアプリケーションは、ドキュメントに記載されているモジュールレベルの `jwt` オブジェクトを使用します。** `from authlib.jose import jwt`
は、`authlib/jose/__init__.py:55-72` で、`["HS256","HS384","HS512","RS256",...,"EdDSA"]` という完全なリストとともに構築されます。また、`JsonWebToken.decode`
(`authlib/jose/rfc7519/jwt.py:80`) は、**`algorithms` パラメータを公開していません**。そのため、ドキュメントに記載されているオブジェクトを使用する呼び出し元は、使用するアルゴリズムを限定する方法がありません。これは、`docs/jose/jwt.rst:21-31` に記載されている最初のコード例です。

この問題点はすでにドキュメントに記載されています。`docs/jose/jwt.rst:73-79` には「このデコード方法は安全ではありません。デフォルトでは `jwt.decode` が `alg` ヘッダーを解析します。これにより、対称的なMACと非対称的な署名が可能です。」と記載されており、`docs/jose/jwt.rst:94-98` には「対称的な(HS)署名スキームと非対称的な(RS、ES、PS)署名スキームを組み合わせるべきではありません。」と記載されています。このレポートの目的は、**混合されたリストが存在することを示すことではありません**。むしろ、`oct_key.py:81` に記載されている、このドキュメントに記載されている問題点を回避するためのバックアップ機能が、実際に機能していないことを示すことです。

2. **検証キーは、PEM/証明書テキストとして渡されます。このテキストのバイト列は、`-----BEGIN ` で始まっていません。** 以下の形式すべてが、改ざん可能であることが確認されました。また、これらの形式は、同一のバイト列を持つ有効な RS256/ES256 トークンも検証できるため、デプロイメント全体が壊れているわけではありません。
* 先頭に改行がある — これは、トリプルクォートで記述されたPythonリテラルで生成されるもの
* 先頭にスペースがある
* インデントされたYAML/JSON/TOMLの設定ブロック(4つのスペースでインデントされている)
* UTF-8 BOM — これは、Windowsのエディタによって生成されるもの
* 先頭にコメント行がある
* 上記すべてが、`PUBLIC KEY` PEMではなく、X.509 `CERTIFICATE` PEMに適用されている

生のDER SPKIのバイナリデータも、このガードを回避します。ただし、DERは、このパスでは有効な非対称検証に使用できません。そのため、DERはデプロイ可能な構成としては除外され、ここでは、認識/ロードのギャップを示すさらなる証拠としてのみ言及されます。

## 詳細

`authlib/jose/rfc7518/oct_key.py:10-17`

```python
POSSIBLE_UNSAFE_KEYS = (
b"-----BEGIN ",
b"---- BEGIN ",
b"ssh-rsa ",
b"ssh-dss ",
b"ssh-ed25519 ",
b"ecdsa-sha2-",
)
```

`authlib/jose/rfc7518/oct_key.py:78-84`

```python
raw_key = to_bytes(raw)

# セキュリティチェック
if raw_key.startswith(POSSIBLE_UNSAFE_KEYS):
raise ValueError("このキーは安全にインポートできない可能性があります")

key = cls(raw_key=raw_key, options=options)
```

`bytes.startswith` は固定文字列マッチングを行います。一方、非対称鍵のロード処理では、固定文字列マッチングが行われません。
`authlib/jose/rfc7517/_cryptography_key.py:22-29` が `load_pem_public_key` / `load_pem_x509_certificate` を呼び出しており、これらは両方とも、アームヘッダーを前方から検索します。したがって、認識エンジンが拒否するバイト文字列の集合は、ロード処理が受け入れる集合よりも厳密に小さく、その差分が悪用される可能性があります。

ドキュメント化されたオブジェクトの呼び出し元にとって、以下の箇所が関連します。

* `authlib/jose/rfc7518/jws_algs.py:59` — `HMACAlgorithm.prepare_key(raw_data)` が `OctKey.import_key(raw_data)` を返します。これが、HMACキーの処理の全てです。
* `authlib/jose/rfc7519/jwt.py:80` — `JsonWebToken.decode(self, s, key, claims_cls=None, claims_options=None, claims_params=None)`。アルゴリズムはトークンヘッダーから取得され、インスタンスのリストに対してのみチェックされます。
* `authlib/jose/__init__.py:55-72` — そのリストには、HS\*、RS\*/ES\*/PS\*/EdDSA が含まれています。

したがって、`oct_key.py:81` が、許可リストと公開鍵をHMACシークレットとして使用する間の唯一の区切りであり、これは単一の先頭バイトによって回避されます。

## PoC (概念実証)

`poc-authlib-e2e-http.py` は、Flask API を起動し、認証はドキュメント化された `jwt.decode(token, PUBLIC_KEY)` のみを使用します。ここで、`PUBLIC_KEY` は、発行者のRSA公開鍵を三重引用符で囲んだPythonリテラルとして埋め込んでいます(したがって、先頭に改行が1つあります)。攻撃者には、**その公開鍵のみ**が提供されます。

```
authlib=1.8.0 cryptography=50.0.2 flask=3.1.3
guard = authlib/jose/rfc7518/oct_key.py:81 raw_key.startswith(POSSIBLE_UNSAFE_KEYS)
call = authlib/jose/rfc7519/jwt.py:80 JsonWebToken.decode(s, key) [アルゴリズムパラメータなし]
algs = authlib/jose/__init__.py:55+ モジュールレベル jwt = JsonWebToken([HS256..RS256..ES256..])

[ベースライン 1] 正しいRS256トークン、ロール=ユーザー -> (403, '{"error":"not admin","sub":"alice"}')
[ベースライン 2] 正しいRS256トークン、ロール=管理者 -> (200, '{"data":"DB_ROOT_PASSWORD=hunter2; STRIPE_SK=sk_live_deadbeef","ok":true,"sub":"root"}')
[コントロール 1 ] ペイロードが改ざんされ、RS256の署名が維持されている -> (401, '{"error":"BadSignatureError: bad_signature: "}')
[コントロール 2 ] 公開鍵を使用してHS256を偽造 -> キーインポート時にブロック: ValueError: このキーは安全でない可能性があります
[コントロール 3 ] JsonWebToken(['RS256']) と偽造されたトークン -> 拒否: UnsupportedAlgorithmError

攻撃者の入力: IDP_PUB_CANONICALのみ(定義上、公開鍵)。秘密鍵は使用しない。
偽造されたトークンのヘッダー: {'alg': 'HS256', 'typ': 'JWT'}
[攻撃 ] 公開鍵を使用してHS256を偽造 -> (200, '{"data":"DB_ROOT_PASSWORD=hunter2; STRIPE_SK=sk_live_deadbeef","ok":true,"sub":"attacker"}')
```

これらの3つのコントロールは、guardがPEM形式の公開鍵で正常に機能すること(先頭のバイトが重要)、通常の署名検証が有効であること、およびアルゴリズムの固定が有効な対策であることを示しています。

すべての鍵タイプについて、コントロールを含めた検証マトリックスを作成し、正当な非対称検証が同じバイトで成功することを確認します。

### RSA-2048/RS256
標準的なPEM形式 (CONTROL A) loader=load_pem_public_key startswith_guard=True RS/ES-verify=OK BLOCKED:ValueError
インデントされたPEM形式 (4スペース) loader=load_pem_public_key startswith_guard=False RS/ES-verify=OK FORGED sub=superadmin role=admin
UTF-8 BOMプレフィックス loader=load_pem_public_key startswith_guard=False RS/ES-verify=OK FORGED sub=superadmin role=admin
先頭のコメント行 loader=load_pem_public_key startswith_guard=False RS/ES-verify=OK FORGED sub=superadmin role=admin
先頭の空白行 loader=load_pem_public_key startswith_guard=False RS/ES-verify=OK FORGED sub=superadmin role=admin
先頭の単一行スペース loader=load_pem_public_key startswith_guard=False RS/ES-verify=OK FORGED sub=superadmin role=admin
DER (SPKI) バイト loader=load_der_public_key startswith_guard=False RS/ES-verify=FAIL FORGED sub=superadmin role=admin
[CONTROL C] JsonWebToken(['RS256']).decode(偽造されたHS256) -> REJECTED:UnsupportedAlgorithmError

### EC-P256/ES256
すべての7つの行で同じ結果が得られる

X.509証明書のPEM形式にも同じ5つの先頭バイト形式が適用されると、同様に偽造され、RS256-verify=OKとなる。

### 再現手順

1. `pip install authlib==1.8.0 cryptography flask requests`
2. RSA-2048の鍵ペアを生成し、公開鍵をPEM形式でシリアライズし、先頭に改行を追加する
(またはインデントするか、BOMまたはコメント行を追加する)。
3. `authlib.jose.rfc7518.oct_key.POSSIBLE_UNSAFE_KEYS` が、`startswith`でこれらのバイトと一致しないことを確認し、
一方で `cryptography.hazmat.primitives.serialization.load_pem_public_key` がそれらをロードすることを確認する。
4. `from authlib.jose import jwt` を実行し、
`tok = jwt.encode({"alg":"HS256"}, {"sub":"attacker","role":"admin"}, mutated_pem)` を実行する。
5. `jwt.decode(tok, mutated_pem)` を実行すると、検証が成功し、クレームが返される。

## 影響

暗号学的署名検証の回避により、認証の回避(CWE-347、アルゴリズムの混乱)。認証されていない攻撃者が、発行者の公開検証キー(定義上、通常は`/.well-known/jwks.json`またはIdPのメタデータで提供される)を知っている場合、任意のクレームを含むトークンを偽造し、管理者の役割や任意の主体を設定することができます。このPoCでは、HTTP 200が返され、アプリケーションの管理者秘密が`sub=attacker`として取得されます。

Authlibは、過去1ヶ月間に**103,356,930回**のPyPIダウンロードがありました(pypistats `/api/packages/authlib/recent`、2026年10月1日現在)。そのため、私は、単にドキュメントに記載されている脆弱性だけでなく、ガードの回避についても報告しています。

完全性のため、トリアージのための注意点として、以下の2点があります。

* `authlib.jose`は`AuthlibDeprecationWarning`(「joserfcを使用してください。バージョン2.0.0以前でも互換性があります」)を出力します。これは非推奨であり、まだ配布されており、`docs/jose/jwt.rst`に記載されており、上記のダウンロード数も依然として存在します。
* **joserfcは、このメカニズムの影響を受けません。** 1.7.5のバージョンが確認されました。キーはデータモデルに明示的な`kty`を含んでおり、そのため、`jwt.decode`で`RSAKey`/`ECKey`を使用し、`alg=HS256`を指定すると、7つのロード可能形式すべてで`InvalidKeyTypeError`が発生します。キーのバイト列を検査するのではなく、キーの型をチェックすることが、そのクラスが表現不可能になる原因です。

## 推奨される対策

これらのいずれかの対策により、この問題は解決されます。

1. 鍵の種類を**バイト数ではなく、種類で**判断します。候補となる鍵に対して、`load_pem_public_key`、`load_pem_x509_certificate`、`load_ssh_public_key`、`load_der_public_key`のいずれかを試してみて、もしどれか一つでも成功した場合、その鍵を`oct`型の鍵として拒否します。このアプローチにより、joserfcは安全であり、ローダーとの整合性を保つことができます。なぜなら、これはローダーそのものだからです。
2. もし簡易的な識別機能を維持する必要がある場合は、固定されたプレフィックスチェックではなく、部分文字列のスキャンを使用します(`any(m in raw_key for m in POSSIBLE_UNSAFE_KEYS)`)。そして、`-----BEGIN CERTIFICATE-----`とDER形式の`SubjectPublicKeyInfo`のケースを追加します。これは(1)よりも厳密には弱いです。識別機能が再び、`cryptography`がより寛容になった際に、誤った結果を返す可能性があります。

また、このレポートとは別に、`JsonWebToken.decode`に`algorithms=`パラメータを渡すことで、ドキュメントで推奨されている対策を、別の`JsonWebToken`を構築することなく、ドキュメントされたモジュールレベルのオブジェクトの呼び出し元が適用できるようになります。

## 貢献者

zer0d4y5によって発見されました。リリース前にパッチのテストを喜んで行います。

影響を受けるパッケージ:
- pip (Python) authlib: 影響を受けるバージョンは1.3.1以上、1.8.0以下、1.9.0で修正済み。