🎄 科学と神々株式会社 アドベントカレンダー 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;
}
}
🌟 まとめ
今日学んだこと:
-
署名生成の実装
- crypto.createSign()
- データの正規化
- Base64エンコード
-
署名検証の実装
- crypto.createVerify()
- 公開鍵での検証
-
セキュリティ
- データの正規化
- タイムスタンプ検証
- エラーハンドリング
💡 次回予告
Day 9: 改ざん防止の仕組み
攻撃者の視点から見て、なぜ署名が安全なのかを学びます。
お楽しみに!
前回: Day 7: JWT(JSON Web Token)入門
次回: Day 9: 改ざん防止の仕組み
Happy Learning! 🎉