きっかけ
バックエンドを触っていると JWT を見る機会が多い。localStorage、Authorization ヘッダ、.env ファイル、curl のレスポンス。中身を確認するとき、いつも jwt.io を開いてペーストしていた。
でもそれは「ターミナルから離れてブラウザに行く」「他人の JS にプロダクションのトークンを貼る」「jq にパイプできない」ということ。CLI なら jwt-inspect $TOKEN 一発で済む。
そしてもう一つ、jose や jsonwebtoken に頼らず自分で書くことで、JWT の署名検証が実際に何をしているのかを理解したかった。
作ったもの
jwt-inspect -- JWT のデコードと署名検証を行うゼロ依存 TypeScript CLI。
GitHub: https://github.com/sen-ltd/jwt-inspect
- HS256/384/512、RS256/384/512、ES256/384、EdDSA に対応
-
--verify --secretまたは--public-keyで署名検証 -
exp/iat/nbfの現在時刻との差分を表示 -
--format jsonで jq にパイプ可能 - ランタイム依存ゼロ(Node の
cryptoモジュールのみ使用) - 46 テスト
技術的なポイント
base64url は base64 ではない
JWT のセグメントは base64url エンコード(RFC 7515)。base64 との違いは 3 つ:
-
+→- -
/→_ - 末尾の
=パディングを除去
デコード時にはパディングを復元する必要がある。入力長が 4k+1 になることはないので、4k+2 なら ==、4k+3 なら = を付ける。
署名入力は「エンコード済み文字列」
JWT は H.P.S の 3 セグメント。署名 S は、デコード済み JSON に対するものではなく、base64url エンコードされたままの ASCII 文字列 H.P をバイト列として署名したもの。JSON を再シリアライズしたら、キーの順序や空白が変わって署名が一致しなくなる。
return {
header, payload,
signingInput: `${h}.${p}`, // ← 受信したままの文字列
};
alg: none は無条件で拒否
{"alg":"none"} で署名を省略し、サーバーが「none だから検証不要」と受け入れてしまう攻撃は、実際のライブラリで何年にもわたり CVE を生んだ。トークンのヘッダに書かれた alg を信頼してはいけない。
if (alg === 'none' || alg === 'None' || alg === 'NONE') {
throw new VerifyError('alg "none" is refused on verify', 'alg_none');
}
--alg RS256 で期待するアルゴリズムを明示できる。これは HS/RS アルゴリズム混同攻撃(サーバーが RSA 公開鍵を HMAC の secret として使ってしまう)への防御でもある。
Node の crypto だけで全アルゴリズムをカバー
// HMAC
const mac = createHmac('sha256', secret).update(signingInput).digest();
return timingSafeEqual(mac, token.signatureBytes);
// RSA PKCS#1 v1.5
const v = createVerify('RSA-SHA256');
v.update(signingInput);
return v.verify(key, token.signatureBytes);
// ECDSA -- dsaEncoding: 'ieee-p1363' が鍵
const v = createVerify('SHA256');
return v.verify({ key, dsaEncoding: 'ieee-p1363' }, token.signatureBytes);
// EdDSA
return edVerify(null, signingInput, key, token.signatureBytes);
ECDSA の署名は JOSE が raw r || s 連結、OpenSSL が ASN.1 DER という違いがある。最初は手動で DER 変換していたが、dsaEncoding: 'ieee-p1363' を見つけて 50 行削除できた。
HMAC の比較には timingSafeEqual を使う。=== だとバイト単位のタイミングリークでシグネチャが推測される可能性がある。
テスト: 鍵ペアを生成して end-to-end で検証
const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
it('verifies an RS256 JWT end-to-end', () => {
const signer = createSign('RSA-SHA256');
signer.update(signingInput);
const sig = signer.sign(privateKey);
const token = `${signingInput}.${base64urlEncode(sig)}`;
expect(verify(decode(token), { publicKey: pem })).toBe(true);
});
テスト実行のたびにフレッシュな鍵ペアを生成。固定のフィクスチャに頼らない。隣のテストで署名の 1 バイトを反転させて false を確認。46 テスト、約 800 ms。
30 秒で試す
git clone https://github.com/sen-ltd/jwt-inspect.git
cd jwt-inspect && docker build -t jwt-inspect .
TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
docker run --rm jwt-inspect "$TOKEN"
docker run --rm jwt-inspect "$TOKEN" --verify --secret "your-256-bit-secret"
docker run --rm jwt-inspect "$TOKEN" --format json | jq .
おわりに
JWT の「標準」は実は小さい。3 つの base64url セグメント、署名入力は header.payload の ASCII バイト、Node の crypto が全アルゴリズムをカバー。本当にトリッキーなのは (a) ヘッダの自己申告アルゴリズムを信頼しないこと、(b) ECDSA 署名のフォーマットが 2 種類あること、の 2 点だけだった。
本番では jose を使うべき。でも一度自分で書いてみると、jose のソースを読むときに何を見ればいいかがわかるようになる。
