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?

License System Day 8: 署名検証の実装

Last updated at Posted at 2025-12-07

🎄 科学と神々株式会社 アドベントカレンダー 2025

License System Day 8: 署名検証の実装


📖 今日のテーマ

これまで学んだECDSA署名とJWTを、実際のコードで実装します。

サーバー側での署名生成から、クライアント側での検証まで、完全な実装例を見ていきましょう。


🔐 署名生成(サーバー側)

実装の全体像

// server/src/crypto.js
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');

class CryptoService {
  constructor() {
    // 秘密鍵の読み込み
    this.privateKey = fs.readFileSync(
      path.join(__dirname, '../keys/private_key.pem'),
      'utf8'
    );
  }

  /**
   * データに署名を付与
   * @param {Object} data - 署名するデータ
   * @returns {string} Base64エンコードされた署名
   */
  sign(data) {
    try {
      // 署名オブジェクトの作成
      const sign = crypto.createSign('SHA256');
      
      // データを正規化してハッシュ化
      const canonical = JSON.stringify(data, Object.keys(data).sort());
      sign.update(canonical);
      sign.end();

      // 署名生成
      const signature = sign.sign(this.privateKey, 'base64');
      
      return signature;
    } catch (error) {
      throw new Error(`署名生成エラー: ${error.message}`);
    }
  }
}

module.exports = new CryptoService();

API レスポンスでの使用

// server/src/routes/license.js
const express = require('express');
const router = express.Router();
const cryptoService = require('../crypto');

router.post('/activate', async (req, res) => {
  try {
    // ライセンスの発行処理
    const responseData = {
      status: 'activated',
      license_id: generateLicenseId(),
      plan_type: 'premium',
      expires_at: getExpiryDate(),
      timestamp: Date.now()
    };

    // 署名を生成
    const signature = cryptoService.sign(responseData);

    // レスポンスに署名を追加
    res.json({
      ...responseData,
      signature
    });

  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

✅ 署名検証(クライアント側)

実装の全体像

// client/src/license.js
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');

class LicenseVerifier {
  constructor() {
    // 公開鍵の読み込み
    this.publicKey = fs.readFileSync(
      path.join(__dirname, '../keys/public_key.pem'),
      'utf8'
    );
  }

  /**
   * 署名を検証
   * @param {Object} data - 検証するデータ
   * @param {string} signature - Base64エンコードされた署名
   * @returns {boolean} 検証結果
   */
  verify(data, signature) {
    try {
      // 検証オブジェクトの作成
      const verify = crypto.createVerify('SHA256');
      
      // データを正規化(サーバーと同じ順序)
      const canonical = JSON.stringify(data, Object.keys(data).sort());
      verify.update(canonical);
      verify.end();

      // 署名検証
      const isValid = verify.verify(this.publicKey, signature, 'base64');
      
      return isValid;
    } catch (error) {
      console.error('署名検証エラー:', error.message);
      return false;
    }
  }

  /**
   * レスポンス全体を検証
   * @param {Object} response - サーバーからのレスポンス
   * @returns {boolean} 検証結果
   */
  verifyResponse(response) {
    const { signature, ...data } = response;
    
    if (!signature) {
      throw new Error('署名がありません');
    }

    return this.verify(data, signature);
  }
}

module.exports = new LicenseVerifier();

使用例

const licenseVerifier = require('./license');

// API レスポンスの検証
async function activateLicense(email, password) {
  const response = await fetch('http://localhost:3000/api/v1/license/activate', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email, password })
  });

  const data = await response.json();

  // 署名検証
  const isValid = licenseVerifier.verifyResponse(data);

  if (!isValid) {
    throw new Error('⚠️  署名が不正です!データが改ざんされている可能性があります');
  }

  console.log('✅ 署名検証成功!');
  return data;
}

🛡️ セキュリティベストプラクティス

1. データの正規化

// ❌ 危険: オブジェクトの順序が異なると署名が変わる
JSON.stringify({ b: 2, a: 1 }) !== JSON.stringify({ a: 1, b: 2 })

// ✅ 安全: キーをソートして正規化
function canonicalize(obj) {
  return JSON.stringify(obj, Object.keys(obj).sort());
}

canonicalize({ b: 2, a: 1 }) === canonicalize({ a: 1, b: 2 })
// → true

2. タイムスタンプの検証

function verifyTimestamp(data, maxAge = 300000) { // 5分
  const now = Date.now();
  const timestamp = data.timestamp;

  if (!timestamp) {
    throw new Error('タイムスタンプがありません');
  }

  const age = now - timestamp;

  if (age > maxAge) {
    throw new Error('レスポンスが古すぎます(リプレイ攻撃の可能性)');
  }

  if (age < -60000) { // 未来の1分以上
    throw new Error('タイムスタンプが不正です');
  }

  return true;
}

3. エラーハンドリング

class SignatureError extends Error {
  constructor(message) {
    super(message);
    this.name = 'SignatureError';
  }
}

function verifyWithErrorHandling(data, signature) {
  try {
    // 基本的な検証
    if (!data || !signature) {
      throw new SignatureError('データまたは署名が空です');
    }

    // タイムスタンプ検証
    verifyTimestamp(data);

    // 署名検証
    const isValid = licenseVerifier.verify(data, signature);

    if (!isValid) {
      throw new SignatureError('署名が不正です');
    }

    return true;

  } catch (error) {
    if (error instanceof SignatureError) {
      console.error('🚨 セキュリティエラー:', error.message);
      // アラート送信など
    } else {
      console.error('❌ 検証エラー:', error.message);
    }
    return false;
  }
}

🌟 まとめ

今日学んだこと:

  1. 署名生成の実装

    • crypto.createSign()
    • データの正規化
    • Base64エンコード
  2. 署名検証の実装

    • crypto.createVerify()
    • 公開鍵での検証
  3. セキュリティ

    • データの正規化
    • タイムスタンプ検証
    • エラーハンドリング

💡 次回予告

Day 9: 改ざん防止の仕組み

攻撃者の視点から見て、なぜ署名が安全なのかを学びます。

お楽しみに!


前回: Day 7: JWT(JSON Web Token)入門
次回: Day 9: 改ざん防止の仕組み

Happy Learning! 🎉

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?