jsonwebtokenからjoseへ移行する際の PEM 形式問題と解決策


## はじめに

Node.js プロジェクトで JWT (JSON Web Token) の検証を行う際、多くの開発者が従来の `jsonwebtoken` ライブラリから、よりモダンな `jose` ライブラリへの移行を検討します。しかし、この移行プロセス中に、公開鍵のフォーマットが互換性のない問題に遭遇することがよくあります。この記事では、私が移行中に直面した問題と、その最終的な解決策について共有します。

---

## 結論：`crypto` モジュールで PEM 形式を統一する

**TL;DR**: `jsonwebtoken` から `jose` に移行する際、 `Invalid keyData` または `wrong tag` エラーが発生した場合、Node.js の `crypto` モジュールを使用して PEM 形式を標準化する必要があります。

```javascript
import * as crypto from 'crypto';
import * as jose from 'jose';

// crypto を使用して PEM 形式を標準化
const key = crypto.createPublicKey(ssoPublicKeyPem);
const fixedPem = key.export({
  type: 'spki',
  format: 'pem'
});

// 標準化された PEM を使用して jose で検証
const publicKey = await jose.importSPKI(fixedPem, 'RS256');
const { payload } = await jose.jwtVerify(token, publicKey);
```

---

## 少し苦戦したデバッグ

### 従来の `jsonwebtoken` による実装

当初、私は `jsonwebtoken` ライブラリを使って JWT 検証を行っていました。そのコードは非常にシンプルでした。

```javascript
import jwt from 'jsonwebtoken';

const ssoPublicKey = process.env.SSO_PUBLIC_KEY.replace(/\\n/g, '\n') || '';
const payload = jwt.verify(token, ssoPublicKey);
console.log('JWT 検証成功、Payload:', payload);
```

この実装は問題なく動作していました。

### `jose` への移行時に発生したエラー

コードを `jose` ライブラリに移行しようとした際、最初の実装は以下のようになりました。

```javascript
import * as jose from 'jose';

const ssoPublicKeyPem = process.env.SSO_PUBLIC_KEY.replace(/\\n/g, '\n') || '';
const publicKey = await jose.importSPKI(ssoPublicKeyPem, 'RS256');
```

しかし、実行時に以下のエラーが発生しました。

```
DOMException [DataError]: Invalid keyData
    at Object.rsaImportKey (node:internal/crypto/rsa:221:15)
    at SubtleCrypto.importKey (node:internal/crypto/webcrypto:615:10)
    ...
    [cause]: Error: error:068000A8:asn1 encoding routines::wrong tag
```

### PEM のヘッダ/フッタの変更を試みる

エラーメッセージを見て、まず PEM 形式の問題を疑いました。環境変数内の公開鍵が以下のようになっていることに気づきました。

```
SSO_PUBLIC_KEY="-----BEGIN RSA PUBLIC KEY-----\n...
```

そこで、ヘッダとフッタを `RSA PUBLIC KEY` から `PUBLIC KEY` に変更することを試みました。

```javascript
// ヘッダ/フッタ形式の変更を試みる
let ssoPublicKeyPem = process.env.SSO_PUBLIC_KEY.replace(/\\n/g, '\n') || '';
ssoPublicKeyPem = ssoPublicKeyPem
  .replace('-----BEGIN RSA PUBLIC KEY-----', '-----BEGIN PUBLIC KEY-----')
  .replace('-----END RSA PUBLIC KEY-----', '-----END PUBLIC KEY-----');
```

しかし、このように変更しても同じエラーが発生しました。

---

## 2つのライブラリの内部処理メカニズムの差異

### `jsonwebtoken` の処理方法

`jsonwebtoken` ライブラリは内部で、より緩やかな鍵解析メカニズムを使用しています。これにより、以下のことが可能です。

* 様々な PEM 形式（`RSA PUBLIC KEY`、`PUBLIC KEY` など）を自動的に処理
* フォーマットエラーに対する一定の許容性
* 内部でいくつかの形式変換と標準化を実行

### `jose` ライブラリの処理方法

一方、`jose` ライブラリはより厳格です。

* Web Crypto API 標準に厳密に従って鍵を解析
* PEM 形式が標準の SPKI 形式（`-----BEGIN PUBLIC KEY-----`）であることを要求
* ASN.1 エンコーディングに対してより厳格な要件
* Node.js の Web Crypto API を直接呼び出し

---

## なぜ `crypto` モジュールが必要なのか

問題の根本原因は、PEM のヘッダ/フッタを手動で変更したとしても、内部の **ASN.1 エンコーディング構造**が `jose` ライブラリの要件を満たしていない可能性がある点にあります。

Node.js の `crypto.createPublicKey()` メソッドは、以下のことを実現します。

1.  **様々な形式の公開鍵を自動的に識別**（`RSA PUBLIC KEY`、`PUBLIC KEY` など）
2.  **内部エンコーディング構造を標準化**し、ASN.1 エンコーディングが標準に準拠していることを保証
3.  **標準の SPKI 形式を出力**。これこそが `jose.importSPKI()` が必要とするものです。

```javascript
// crypto.createPublicKey の処理プロセス：
// 1. 元の PEM を解析（どんな形式でも）
// 2. 公開鍵の数学的パラメータを抽出
// 3. 標準の SPKI 形式に再エンコード
// 4. 標準の PEM 文字列を出力

const key = crypto.createPublicKey(originalPem);
const standardizedPem = key.export({
  type: 'spki', // SPKI 形式での出力を指定
  format: 'pem' // PEM 文字列での出力を指定
});
```

---

## まとめ

### 修正後の `jose` による実装

```javascript
import dotenv from 'dotenv';
dotenv.config({ path: '../.env' }); // .env ファイルのパスは適宜調整してください

import * as jose from 'jose';
import * as crypto from 'crypto';

// 環境変数内の公開鍵を処理
const ssoPublicKeyPem = process.env.SSO_PUBLIC_KEY.replace(/\\n/g, '\n') || '';
console.log(`debug:ssoPublicKeyPem: ${ssoPublicKeyPem}`);

// Node.js の crypto モジュールを使用して PEM 形式を標準化
const key = crypto.createPublicKey(ssoPublicKeyPem);
const fixedPem = key.export({
  type: 'spki',
  format: 'pem'
});
console.log(`debug:fixedPem: ${fixedPem}`);

// JWT トークン検証 (例として dummy token を使用)
// 実際のアプリケーションでは検証対象の JWT を渡してください
const token = 'YOUR_JWT_TOKEN_HERE'; // ここに検証したい JWT を入れます

async function verifyToken() {
  try {
    // 標準化された PEM 形式から公開鍵をインポート
    const publicKey = await jose.importSPKI(fixedPem, 'RS256');

    // JWT を検証
    const { payload, protectedHeader } = await jose.jwtVerify(token, publicKey);

    console.log('JWT 検証成功、Protected Header:', protectedHeader);
    console.log('JWT 検証成功、Payload:', payload);

    return payload;
  } catch (error) {
    console.error('JWT 検証失敗:', error);
    throw error;
  }
}

// 関数の呼び出し (テスト用)
verifyToken();
```

`jsonwebtoken` から `jose` へ移行する際、重要なのは、両ライブラリの公開鍵形式に対する要求の違いを理解することです。`jose` ライブラリはより厳格で標準化されており、これによって多少の複雑性が増しますが、その分、パフォーマンス向上とより厳格なセキュリティを実現します。

`crypto.createPublicKey()` を用いた形式の標準化は、公開鍵形式の互換性を確保し、移行プロセスをよりスムーズにするための確実な解決策です。

---

## 参考資料

* [jose ライブラリ公式ドキュメント](https://github.com/panva/jose)
* [Node.js Crypto API ドキュメント](https://nodejs.org/api/crypto.html)
* [RFC 7517 - JSON Web Key (JWK)](https://tools.ietf.org/html/rfc7517)

---

## あとがき

この Qiita 記事が、`jsonwebtoken` から `jose` への移行でお困りの方々の一助となれば幸いです。初めて技術的な記事を書くので、本当にこのテーマで大丈夫か自信がないです。もし疑問点があれば、お気軽にコメントください。

ちなみに、この記事の本文はGitHub Copilotを使って書いてもらったものです。私がデバッグする際、Google検索に加えて、Copilotにも問題解決の手助けをしてもらったからです。Copilotは、私が遭遇したエラーも記録してくれて、指定したアウトラインに沿って自動的に.mdファイルに書き込むことができます。出来上がった記事を自分で読み直し、不要だと感じた比較表の一部を削除したほかは、ほとんど手を加えていません。本当に助かりました。