HMACを使ったAPIのセキュア認証の実装

## 背景
レコードを更新するために、APIの用意が必要になってきました。APIの認証はセキュリティを考慮して、HMAC認証を採用しました。

環境
 
- クライアント側
  - Google Cloud Run Function
- サーバー側
  - AWS ECS: Rails

## HMACとは

HMAC (Hash-based Message Authentication Code) は、秘密鍵とハッシュ関数を使用したメッセージ認証の仕組みです。主な利点は：

- 秘密鍵を知らないと正しいHMACを生成できないため安全
- 多くの言語でライブラリが提供されており実装が容易
- サーバー側でユーザー状態を保持する必要がなくスケーラブル
- タイムスタンプと組み合わせることでリプレイアタックを防止可能

## 実装の重要ポイント

### 1. 秘密鍵の管理
- **安全な生成**: 32バイト(256ビット)以上の長さで生成
- **環境別の管理**: 開発・ステージング・本番環境で異なる鍵を使用
- **Google Cloud Functions**: 環境変数として設定
- **AWS ECS**: Parameter Storeを使用して安全に保存・取得

### 2. タイムスタンプと有効期限
- **UTC時間を使用**: タイムゾーンの差異を防止
- **ISO8601形式**: 言語間の互換性確保（例: `2025-05-21T14:30:00Z`）
- **有効期限の設定**: 5分以内など短時間に制限し、リプレイアタック防止

### 3. 一貫した署名データ構造
```
[タイムスタンプ ISO8601形式]
[HTTPメソッド]
[エンドポイントパス]
[リクエストボディJSON]
```
- クライアントとサーバーで必ず同じ形式・順序で構成
- 改行コード（`\n`）は正確に一致させる

### 4. HTTPヘッダーの標準化
- `X-HMAC-Signature`: 計算したHMAC署名
- `X-HMAC-Timestamp`: ISO8601形式のタイムスタンプ
- `Content-Type: application/json`: JSONデータの場合

## フロー

```mermaid
sequenceDiagram
    participant クライアント (Cloud Run)
    participant サーバー (Rails on ECS)

    Note over クライアント (Cloud Run): 1. タイムスタンプ生成(UTC時間)
    Note over クライアント (Cloud Run): 2. データと秘密鍵からHMAC生成
    クライアント (Cloud Run)->>サーバー (Rails on ECS): リクエスト (ヘッダーにHMACとタイムスタンプを含む)
    Note over サーバー (Rails on ECS): 3. タイムスタンプ有効期限チェック
    Note over サーバー (Rails on ECS): 4. 受信したデータと秘密鍵からHMACを再計算
    Note over サーバー (Rails on ECS): 5. 送信されたHMACと計算したHMACを比較
    alt HMACが一致し、タイムスタンプが有効
        サーバー (Rails on ECS)->>クライアント (Cloud Run): 200 OK
    else HMACが不一致またはタイムスタンプが無効
        サーバー (Rails on ECS)->>クライアント (Cloud Run): 401 Unauthorized
    end
```

## 秘密鍵の生成と管理
- 十分な長さと複雑さを持つ鍵を使用
- 環境ごとに異なる鍵を使用（開発・ステージング・本番）

```ruby
   # Rubyでの安全な秘密鍵生成例
   require 'securerandom'

   # 32バイト(256ビット)のランダム文字列を生成
   api_secret_key = SecureRandom.hex(32)
   puts api_secret_key  # => "59b5..."
```

**クライアント側（Google Cloud Functions）での鍵管理**

Google Cloud Functionsでは環境変数を使用して秘密鍵を管理します：

```bash
   # デプロイ時に環境変数を設定
   gcloud functions deploy function-name \
     --set-env-vars API_SECRET_KEY=your-secret-key \
     --other-parameters
```

   または、Google Cloud Console上で環境変数を設定することもできます。

**サーバー側（AWS ECS + Rails）での鍵管理**

AWS Parameter Storeを使用して秘密鍵を安全に保存し、アプリケーションから取得します：

```ruby
   # AWS Parameter Storeから秘密鍵を取得する例
   require 'aws-sdk-ssm'

   ssm_client = Aws::SSM::Client.new(region: 'ap-northeast-1')
   parameter = ssm_client.get_parameter({
     name: '/myapp/production/api_secret_key',
     with_decryption: true
   })

   ENV['API_SECRET_KEY'] = parameter.parameter.value
```

ECSタスク定義では、Parameter Store からの値取得を許可するIAMロールをタスクに割り当てることが必要です。

## クライアント側の実装 (Cloud Run Function - Python)

```python
import os
import json
import hmac
import hashlib
import requests
import datetime
from urllib.parse import urljoin

def send_api_request(item_id, operation_data):
    """サンプルAPI呼び出し関数"""
    # 秘密鍵の取得
    hmac_secret = os.getenv('API_SECRET_KEY')

    # UTCタイムスタンプの生成
    timestamp = datetime.datetime.now(datetime.timezone.utc).isoformat()

    # リクエスト情報の準備
    method = 'PATCH'
    endpoint = f"/api/items/{item_id}"
    payload = operation_data
    json_payload = json.dumps(payload)

    # 署名用データ作成
    data_to_sign = f"{timestamp}\n{method}\n{endpoint}\n{json_payload}"

    # HMAC-SHA256署名計算
    signature = hmac.new(
        hmac_secret.encode('utf-8'),
        data_to_sign.encode('utf-8'),
        hashlib.sha256
    ).hexdigest()

    # APIリクエスト送信
    url = urljoin(os.getenv('API_BASE_URL'), endpoint)
    headers = {
        'Content-Type': 'application/json',
        'X-HMAC-Signature': signature,
        'X-HMAC-Timestamp': timestamp
    }

    response = requests.patch(url, data=json_payload, headers=headers)
    return response
```

## サーバー側の実装 (Rails on AWS ECS)

```ruby
# frozen_string_literal: true

class SecuredApiController < ApplicationController
  HMAC_MAX_AGE = 300 # 5分間の有効期限

  before_action :authenticate_request

  private

  # HMACによるリクエスト認証
  def authenticate_request
    return unauthorized('署名がありません') unless hmac_headers_present?
    return bad_request('タイムスタンプが不正です') unless valid_timestamp_format?
    return unauthorized('リクエスト期限切れ') unless timestamp_fresh?
    return unauthorized('署名が無効です') unless valid_signature?
  end

  # 必要なヘッダーが存在するか
  def hmac_headers_present?
    request.headers['X-HMAC-Signature'].present? &&
    request.headers['X-HMAC-Timestamp'].present?
  end

  # タイムスタンプの形式が正しいか
  def valid_timestamp_format?
    @timestamp = Time.iso8601(request.headers['X-HMAC-Timestamp'])
    true
  rescue ArgumentError
    false
  end

  # タイムスタンプが期限内か
  def timestamp_fresh?
    (Time.current - @timestamp).abs < HMAC_MAX_AGE
  end

  # 署名が正しいか
  def valid_signature?
    data = "#{request.headers['X-HMAC-Timestamp']}\n#{request.method}\n#{request.path}\n#{request.raw_post}"
    expected = OpenSSL::HMAC.hexdigest('sha256', ENV['API_SECRET_KEY'], data)
    ActiveSupport::SecurityUtils.secure_compare(expected, request.headers['X-HMAC-Signature'])
  end

  # 認証エラーレスポンス
  def unauthorized(message)
    render json: { error: message }, status: :unauthorized
  end

  # リクエストエラーレスポンス
  def bad_request(message)
    render json: { error: message }, status: :bad_request
  end
end
```


## 参照

- [HMAC認証 (RFC 2104)](https://tools.ietf.org/html/rfc2104)
- [Ruby OpenSSL::HMAC](https://ruby-doc.org/stdlib/libdoc/openssl/rdoc/OpenSSL/HMAC.html)
- [Python hmacモジュール](https://docs.python.org/3/library/hmac.html)