公開鍵をHMACの鍵にするとJWTの署名が通る
JWTのデコードだけならatobとJSON.parseで終わる。
署名検証まで手元でやろうとしたら、急に踏むところが増えた。ヘッダのalgを信じて鍵の使い方を決める検証器は、RS256の公開鍵をそのままHMACの鍵に使った偽トークンを通す。atobはbase64urlのパディングを戻した長さによっては例外を投げる。TypeScriptではUint8ArrayがBufferSourceに渡らない。
JWTデコードツールを書きながら、この3つを順番に踏んだ。下にいくほど地味になる。
ヘッダのalgを信じると、公開鍵がHMACの鍵になる
JWTのヘッダにはalgが入っている。検証器がここを見て鍵の使い方を決める、という書き方が素直に見える。
// 鍵は keyMaterial の1つだけ受け取り、使い方はトークンのヘッダに決めさせる
async function naiveVerify(token, keyMaterial) {
const [header, payload, signature] = token.split('.')
const alg = JSON.parse(atob(header)).alg
if (alg === 'HS256') return verifyHmac(keyMaterial, `${header}.${payload}`, signature)
if (alg === 'RS256') return verifyRsa(keyMaterial, `${header}.${payload}`, signature)
if (alg === 'none') return true
return false
}
鍵を1つしか受け取らないのが効いてくる。RS256を配っているサービスならkeyMaterialはPEMの公開鍵で、これは配布物なので攻撃者も持っている。攻撃者はalgをHS256に書き換えて、そのPEM文字列をHMACのシークレットとして署名し直せばいい。
const forged = `${j({ alg: 'HS256' })}.${j({ sub: 'alice', admin: true })}`
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(publicKeyPem),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
)
const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(forged))
RSA-2048の鍵対をその場で作って、上のnaiveVerifyに食わせた実測。
| 渡したトークン | 返り |
|---|---|
本物のRS256(admin: false) | true |
algをHS256にして公開鍵PEMで署名し直したもの(admin: true) | true |
alg: none、署名部は空文字 | true |
adminがfalseからtrueに変わったトークンが通っている。秘密鍵は一度も要らない。
alg: noneはもっと分かりやすくて、署名部の長さが0のトークンがそのまま通る。「署名が無いから検証できない」ではなく「誰でも中身を書き換えられる」が正しい読み方で、主要言語のJWTライブラリがまとめてこれを受け入れる実装だった、という報告が2015年に出ている(Tim McLean「Critical vulnerabilities in JSON Web Token libraries」)。
鍵の種別をトークンの外から決める
対策は、algから鍵の使い方を導かないこと。検証する側は最初から「自分はHS256のシークレットで検証する」か「RS256の公開鍵で検証する」かを知っているはずなので、それを引数の型に出した。
export type JwtKey = { kind: 'secret'; value: string } | { kind: 'pem'; value: string }
そのうえで、algとkindの突き合わせを検証関数の中に置く。
switch (decoded.alg) {
case 'HS256':
if (key.kind !== 'secret') return { error: 'HS256の検証にはシークレットが要る。' }
// ...
case 'RS256':
if (key.kind !== 'pem') return { error: 'RS256の検証には公開鍵が要る。' }
// ...
case 'none':
return { error: '署名の無いトークン(alg:none)は検証できない。' }
default:
return { error: `対応していないアルゴリズム: ${decoded.alg}` }
}
組み合わせを一通り通した実測。
| トークン | 渡した鍵 | 結果 |
|---|---|---|
| RS256 | 対になる公開鍵 | valid |
| RS256 | シークレット | RS256の検証には公開鍵が要る。 |
| RS256 | 鍵でないbase64のPEM | 公開鍵をSPKIの公開鍵として読み取れない。 |
| HS256 | 正しいシークレット | valid |
| HS256 | 公開鍵 | HS256の検証にはシークレットが要る。 |
鍵を取り込めなかったときと署名が合わなかったときを別のメッセージにしてあるのは、入力の直しどころが違うから。前者は貼った文字列の形の問題で、後者は本当に署名が合っていない。
base64urlのパディングを戻すと、atobが投げる
JWTの各セグメントはパディング無しのbase64urlなので、atobに渡す前に=を足して4の倍数に揃える。
const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, '=')
これで大体は通るが、元の長さを4で割った余りが1のときだけatobが例外を投げる。
| 入力 | 長さ | %4 | パディング後 | atob |
|---|---|---|---|---|
QUJD | 4 | 0 | QUJD | "ABC" |
QUJDRA | 6 | 2 | QUJDRA== | "ABCD" |
QUJDRUY | 7 | 3 | QUJDRUY= | "ABCEF" |
Q | 1 | 1 | Q=== | InvalidCharacterError |
余り1に足りるパディングは===の3個で、これはbase64の文法として存在しない。base64url1文字を全64パターン試したら、64個とも例外だった。
そもそも余り1は正しいエンコードでは出てこない。0から200バイトをbase64urlにして長さを4で割った余りを集めると0, 2, 3しか出ない。つまり余り1に当たった時点で、入力が途中で切れているか壊れている。パディングを戻す関数の中でこれを弾いてもいいけれど、うちはatobをtryで囲ってnullを返す形にした。壊れた入力の入口はここだけではないので、例外の出どころを1か所にまとめたかった。
ちなみにRS256の署名は256バイトで、base64urlにすると342文字。余りは常に2になる。
PEMは標準base64なので、base64urlのデコーダを使い回せない
同じファイルの中にbase64urlのデコーダがあると、PEMもそれで読みたくなる。読めない。PEMの中身は標準のbase64で、+と/を使う。base64urlのデコーダは^[A-Za-z0-9_-]*={0,2}$のような検査を通してから読むので、+や/が来た時点で弾かれる。
「たまたま記号が出てこない鍵なら通ってしまうのでは」と思って数えた。RSA-2048の公開鍵をSPKIで書き出したbase64は30本とも+か/を含んでいた。ランダムな256バイトを標準base64にしたものも1000本とも含んでいた。344文字のあいだ1文字も記号が出ない確率はおよそ1.8e-5なので、実質必ず含まれる。
なので、PEMを剥がす関数とbase64urlを読む関数は別にして、共通なのはatobのところだけにした。
function pemToSpki(pem: string): Uint8Array<ArrayBuffer> | null {
const body = pem
.replace(/-----BEGIN PUBLIC KEY-----/, '')
.replace(/-----END PUBLIC KEY-----/, '')
.replace(/\s+/g, '')
if (body === '' || !/^[A-Za-z0-9+/]+={0,2}$/.test(body)) return null
return decodeBase64(body)
}
Uint8ArrayがBufferSourceに渡らない
型のほうでも1回止まった。crypto.subtle.digestやimportKeyにUint8Arrayを渡すと、こう言われる。
error TS2345: Argument of type 'Uint8Array<ArrayBufferLike>' is not assignable
to parameter of type 'BufferSource'.
Type 'SharedArrayBuffer' is not assignable to type 'ArrayBuffer'.
TypeScript 5.7でUint8Arrayが裏づけのバッファを型引数に取るようになり、注釈をUint8Arrayとだけ書くと既定のUint8Array<ArrayBufferLike>になる。ArrayBufferLikeにはSharedArrayBufferが入るので、BufferSourceの側が受け取らない。
いつからエラーになるのかを、同じコードをバージョン違いのtscに通して確かめた。
| tsc | 結果 |
|---|---|
| 5.6.3 | Type 'Uint8Array' is not generic(型引数がまだ無い) |
| 5.8.3 | 通る |
| 5.9.2 | TS2345 |
| 6.0.3 | TS2345 |
境目は5.9で、犯人はlib.dom.d.tsのBufferSourceの定義だった。
// 5.8.3
type BufferSource = ArrayBufferView | ArrayBuffer
// 5.9.2 以降
type BufferSource = ArrayBufferView<ArrayBuffer> | ArrayBuffer
Uint8Arrayが型引数を持ったのが5.7で、BufferSourceがその型引数を絞ったのが5.9。両方揃って初めてエラーになるので、5.7に上げた時点では何も起きない。
落ちる書き方と通る書き方も並べて確かめた。
| 書き方 | 結果 |
|---|---|
crypto.subtle.digest('SHA-256', new Uint8Array(4)) | 通る |
crypto.subtle.digest('SHA-256', new TextEncoder().encode('a')) | 通る |
const a: Uint8Array = new Uint8Array(4)を渡す | TS2345 |
function f(a: Uint8Array)の中で渡す | TS2345 |
上の2つをUint8Array<ArrayBuffer>に書き換える | 通る |
生成したそのままの値はUint8Array<ArrayBuffer>に推論されるので何も起きない。Uint8Arrayと注釈を書いた瞬間に型が広がる、という向きの壊れ方だった。関数をまたいでバイト列を持ち回るときだけ踏む。
直し方は注釈をUint8Array<ArrayBuffer>まで絞るだけ。バイト列を扱うツールが増えるたびに同じところで止まったので、src/lib/tools/のうちハッシュ計算とEXIF読みとJWT検証の3つが同じ書き方になっている。
鍵をリポジトリに置かずにテストする
RS256のテストには鍵対が要る。固定の秘密鍵をリポジトリに置くのは、たとえテスト用でも避けたかった。
vitestのenvironment: 'node'ならglobalThis.cryptoがそのまま使えるので、鍵の生成から署名、SPKIの書き出しまでテストの中で完結する。
const pair = await crypto.subtle.generateKey(
{
name: 'RSASSA-PKCS1-v1_5',
modulusLength: 2048,
publicExponent: new Uint8Array([1, 0, 1]),
hash: 'SHA-256',
},
true,
['sign', 'verify'],
)
beforeAllで2組作って、片方で署名して片方で検証に失敗することまで見ている。速度が気になったが、このファイルは30ケースで194ms(鍵対の生成2組込み)だった。気にする話ではなかった。
まとめ
algはトークンの中にある。攻撃者が書き換えられる値から鍵の使い方を導かない- 検証する側は鍵の種別を先に知っている。それを型に出して、
algとの食い違いを検証関数の中で断る alg: noneは「検証できない」ではなく「誰でも書き換えられる」- base64urlのパディング復元は、長さの余りが1のときに
atobが投げる。余り1は壊れた入力の印 - PEMは標準base64。base64urlのデコーダは使い回せない
Uint8Arrayとだけ注釈するとBufferSourceに渡らない。TypeScript 5.9からの挙動