0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

JWT の署名検証を 150 行で自作して JOSE の中身を理解した

0
Posted at

きっかけ

バックエンドを触っていると JWT を見る機会が多い。localStorage、Authorization ヘッダ、.env ファイル、curl のレスポンス。中身を確認するとき、いつも jwt.io を開いてペーストしていた。

でもそれは「ターミナルから離れてブラウザに行く」「他人の JS にプロダクションのトークンを貼る」「jq にパイプできない」ということ。CLI なら jwt-inspect $TOKEN 一発で済む。

そしてもう一つ、josejsonwebtoken に頼らず自分で書くことで、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 のソースを読むときに何を見ればいいかがわかるようになる。

GitHub: https://github.com/sen-ltd/jwt-inspect

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?