本文へ移動
myowchick

公開鍵を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
QUJD40QUJD"ABC"
QUJDRA62QUJDRA=="ABCD"
QUJDRUY73QUJDRUY="ABCEF"
Q11Q===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.3Type 'Uint8Array' is not generic(型引数がまだ無い)
5.8.3通る
5.9.2TS2345
6.0.3TS2345

境目は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からの挙動
← 一覧へ戻る