目次
1. 署名で守るもの
| 目的 | 内容 |
|---|---|
| 真正性(Authenticity) | リクエストが正当な鍵の保持者から送られたことを証明する |
| 完全性(Integrity) | 経路上やプロキシでリクエストが改ざんされていないことを保証する |
| リプレイ防止 | 盗聴・ログ流出したリクエストの再送を無効化する |
| 否認防止(非対称鍵のみ) | 送信者が「送っていない」と言えない状態を作る |
TLS との関係: TLS は通信路を守るが、TLS終端(LB、CDN、APIゲートウェイ)以降やログ・キューに残ったリクエストは守らない。署名はメッセージそのものを守るため、TLS の代替ではなく併用が前提。
2. 方式の選び方
2.1 アルゴリズム
| 方式 | 推奨アルゴリズム | 向いているケース | 注意点 |
|---|---|---|---|
| 共有鍵(対称) | HMAC-SHA256 / HMAC-SHA512 | Webhook、自社クライアント⇔自社API、高スループット | サーバー側も秘密鍵を持つため否認防止不可。サーバー侵害=全鍵漏洩 |
| 公開鍵(非対称) | Ed25519(第一候補)、ECDSA P-256 (SHA-256)、RSA-PSS 3072bit以上 | 外部パートナー連携、否認防止が必要、検証者が多数 | 鍵配布・ローテーションの仕組みが必要 |
避けるもの
- MD5 / SHA-1 ベースの署名・HMAC
- 単なるハッシュ
SHA256(secret + body)(長さ拡張攻撃の対象。必ず HMAC を使う) - RSA PKCS#1 v1.5 の新規採用(既存互換以外では PSS を選ぶ)
- 独自の暗号構成
2.2 既存標準を優先する
自前フォーマットを設計する前に、以下のいずれかに乗れないか検討する。
| 標準 | 概要 |
|---|---|
| RFC 9421 HTTP Message Signatures | HTTP リクエスト/レスポンスの署名に関する IETF 標準。署名対象コンポーネントを明示的に宣言できる |
| RFC 9530 Digest Fields | Content-Digest ヘッダでボディのハッシュを表現 |
| AWS Signature Version 4 | 正規化リクエスト+派生鍵の実績ある方式。AWS互換APIなら有力 |
| JWS (RFC 7515) | JSON ペイロード単位の署名。メッセージがHTTP以外の経路も通る場合に有効 |
| Stripe / GitHub 型 Webhook 署名 | timestamp.body を HMAC する簡潔な方式。Webhook ではデファクト |
3. 署名対象(何に署名するか)
3.1 必ず含めるもの
- HTTP メソッド(GET → DELETE への書き換え防止)
- ホスト / オーディエンス(別環境・別サービスへの転用防止)
- パス
- クエリ文字列
- ボディのダイジェスト(
Content-Digest: sha-256=:...:) - タイムスタンプ(作成時刻)
- 鍵ID(keyid)
- 意味を持つヘッダ(
Content-Type、冪等キー、テナントIDなど)
「ボディだけ署名」「ヘッダだけ署名」は典型的な欠陥。攻撃者は署名されていない部分を書き換える。
3.2 正規化(Canonicalization)
署名者と検証者がバイト単位で同一の文字列を作れることが最重要。
- クエリパラメータ:キーでソート、同一キーの複数値の扱いを定義、パーセントエンコードの大文字/小文字(
%2Fなど)を統一 - パス:
./..の解決、末尾スラッシュ、二重エンコードの扱いを決める - ヘッダ名:小文字化、前後空白除去、複数値の連結ルール
- ボディ:JSON を再シリアライズしない。受け取った生バイト列をそのまま使う
- 文字コード:UTF-8 固定
- 区切り文字:フィールド値に含まれ得ない区切り(改行など)を使い、連結の曖昧さを排除する
# 曖昧な例(NG): "a" + "bc" と "ab" + "c" が同じ文字列になる
payload = field1 + field2
# 改善例: 区切り+固定順序
payload = method + "\n" + path + "\n" + query + "\n" + timestamp + "\n" + body_digest
3.3 署名対象の宣言
RFC 9421 のように「どのコンポーネントを署名したか」を署名メタデータに含め、検証側は必須コンポーネントが含まれているかを必ず確認する(攻撃者が署名対象を減らした署名を作るのを防ぐ)。
4. リプレイ攻撃対策
| 対策 | 推奨値・方法 |
|---|---|
| タイムスタンプ検証 | 許容幅 ±5分程度。署名対象に含めること(含めないと書き換え可能) |
| 有効期限 | expires パラメータで明示するとさらに良い |
| Nonce | リクエストごとに一意な値(UUIDv4 / 128bit乱数)。許容時間窓の間だけ Redis 等に保存して重複を拒否 |
| 冪等キー | 決済など副作用のある操作では Idempotency-Key を併用し、署名対象に含める |
| 時刻同期 | サーバー・クライアントとも NTP 必須。ずれによる誤拒否はエラーコードで判別可能にする |
Nonce ストアは「タイムスタンプ許容幅 × 2」程度の TTL で十分(それより古いものはタイムスタンプで弾ける)。
5. 鍵管理
5.1 生成・保管
- 暗号論的に安全な乱数で生成(HMAC 鍵は 256bit 以上)
- サーバー側は KMS / HSM / Secrets Manager で保管。ソースコード・環境変数ファイル・リポジトリに置かない
- 非対称鍵の場合、秘密鍵はクライアント側のみが保持し、サーバーは公開鍵のみ保存する
- HMAC 鍵を DB に保存する場合は暗号化(エンベロープ暗号化)する
5.2 鍵ID(kid)
- すべての署名に
keyidを付け、検証側は kid から鍵を引く - kid は推測不能である必要はないが、鍵そのものやテナント情報を漏らす値にしない
- kid → 鍵 の対応と同時に kid → 許可アルゴリズム も固定する
5.3 ローテーション
- 1 クライアントに複数の有効鍵を持たせられる設計にする(新旧並行期間を作るため)
- 手順:新鍵発行 → クライアント切替 → 旧鍵の利用ゼロを監視で確認 → 旧鍵失効
- 定期ローテーション(例:90日〜1年)と、漏洩時の即時失効の両方を運用手順化する
- 環境(本番/ステージング/開発)ごとに鍵を分ける
5.4 鍵の用途分離
- 署名用と暗号化用で鍵を共用しない
- 異なる API・用途間で同じ鍵を使わない(必要なら HKDF 等で用途別に派生)
6. 検証側の実装
6.1 検証の順序
- 必須ヘッダ(署名、kid、タイムスタンプ)の存在確認
- タイムスタンプが許容範囲内か(安価な処理を先に)
- kid から鍵とアルゴリズムを取得(存在しない / 失効済みなら拒否)
- 必須コンポーネントが署名対象に含まれているか確認
- ボディの
Content-Digestを生バイト列から再計算して一致確認 - 署名ベース文字列を再構築し、署名を検証
- Nonce の重複確認・記録
- ここで初めてボディをパースし、業務処理へ
6.2 必須ルール
- 定数時間比較を使う(
hmac.compare_digest、crypto.timingSafeEqual、MessageDigest.isEqualなど)。==は使わない - アルゴリズムはサーバー側で固定。リクエスト内の
algを信用しない(alg: noneや HMAC/RSA 混同攻撃の防止) - 署名検証前にボディをパース・処理しない。フレームワークのミドルウェアがボディを消費・整形する場合は生ボディを保持する設定にする
- ボディサイズ上限を設け、検証前の巨大ペイロードによる DoS を防ぐ
- 失敗時のレスポンスは
401と汎用メッセージのみ。「どの部分が違ったか」は返さない(詳細はサーバーログへ) - 期待する署名値をログやエラーレスポンスに出力しない
6.3 デバッグ支援
署名不一致は開発者体験を大きく損なうため、以下を用意すると問い合わせが減る。
- 公式 SDK / 署名ライブラリの提供
- 正規化文字列の仕様とテストベクタ(入力 → 正規化文字列 → 期待署名)の公開
- エラーコードで「時刻ずれ」「未知の kid」「署名不一致」程度は区別(詳細値は返さない)
7. Webhook 送信時の追加ポイント
- 署名ヘッダ例:
X-Signature: t=1700000000,v1=<hex>(複数バージョン併記でローテーション対応) - 署名対象:
timestamp + "." + raw_body - 受信側に「生ボディで検証」「タイムスタンプ確認」「定数時間比較」を明示的にドキュメントで伝える
- エンドポイントごと(顧客ごと)に別のシークレットを発行する
- 送信元 IP 制限は補助策。署名検証の代わりにはならない
- リトライ時は再署名する(タイムスタンプが古くならないように)
8. ログ・監視
- 記録する:kid、クライアントID、検証結果、失敗理由カテゴリ、時刻ずれ量、送信元IP
- 記録しない:秘密鍵、HMAC シークレット、署名値そのもの(必要ならハッシュ化)
- アラート対象:
- 特定 kid の署名失敗率の急増(鍵漏洩・総当たりの兆候)
- 失効済み kid の使用
- Nonce 重複(リプレイ試行)
- 大きな時刻ずれの増加
9. よくある落とし穴
| 落とし穴 | 結果 | 対策 |
|---|---|---|
| ボディのみ署名 | メソッド・パス・クエリを改ざんされる | 3.1 の要素を全て署名 |
| タイムスタンプを署名対象外にする | タイムスタンプを書き換えてリプレイ可能 | 署名対象に含める |
| JSON をパースして再シリアライズして検証 | キー順序・空白差異で不一致、または改ざん見逃し | 生バイト列で検証 |
== で署名比較 | タイミング攻撃 | 定数時間比較 |
リクエストの alg を信用 | アルゴリズム差し替え攻撃 | サーバー側で kid ごとに固定 |
hash(secret + message) | 長さ拡張攻撃 | HMAC を使う |
| 区切りなしの文字列連結 | 正規化の曖昧さによる偽造 | 区切り文字+固定順序 |
| 全クライアント共通シークレット | 1件の漏洩で全体が危殆化 | クライアント/エンドポイント単位で発行 |
| ローテーション不可能な設計 | 漏洩時にサービス停止 | 複数鍵並行・kid 必須 |
| プロキシがヘッダ/クエリを書き換える | 正当なリクエストが失敗 | 署名対象と経路上の変換を事前に整理 |
10. 実装例
10.1 HMAC-SHA256(Python)
import base64
import hashlib
import hmac
import secrets
import time
from urllib.parse import urlencode, parse_qsl
ALLOWED_SKEW = 300 # 秒
def content_digest(body: bytes) -> str:
return "sha-256=:" + base64.b64encode(hashlib.sha256(body).digest()).decode() + ":"
def canonical_query(query: str) -> str:
pairs = sorted(parse_qsl(query, keep_blank_values=True))
return urlencode(pairs, safe="-_.~")
def build_string_to_sign(method, host, path, query, timestamp, nonce, digest, key_id) -> bytes:
parts = [
method.upper(),
host.lower(),
path,
canonical_query(query),
str(timestamp),
nonce,
digest,
key_id,
]
return "\n".join(parts).encode("utf-8")
# ---- 送信側 ----
def sign_request(secret: bytes, key_id, method, host, path, query, body: bytes) -> dict:
ts = int(time.time())
nonce = secrets.token_urlsafe(16)
digest = content_digest(body)
sts = build_string_to_sign(method, host, path, query, ts, nonce, digest, key_id)
sig = base64.b64encode(hmac.new(secret, sts, hashlib.sha256).digest()).decode()
return {
"X-Key-Id": key_id,
"X-Timestamp": str(ts),
"X-Nonce": nonce,
"Content-Digest": digest,
"X-Signature": sig,
}
# ---- 受信側 ----
class SignatureError(Exception):
pass
def verify_request(headers, method, host, path, query, raw_body: bytes,
get_secret, nonce_store) -> str:
try:
key_id = headers["X-Key-Id"]
ts = int(headers["X-Timestamp"])
nonce = headers["X-Nonce"]
digest = headers["Content-Digest"]
sig = headers["X-Signature"]
except (KeyError, ValueError):
raise SignatureError("missing or malformed headers")
if abs(time.time() - ts) > ALLOWED_SKEW:
raise SignatureError("timestamp out of range")
secret = get_secret(key_id) # 失効済み・未知なら None
if secret is None:
raise SignatureError("unknown key")
if not hmac.compare_digest(digest, content_digest(raw_body)):
raise SignatureError("digest mismatch")
sts = build_string_to_sign(method, host, path, query, ts, nonce, digest, key_id)
expected = base64.b64encode(hmac.new(secret, sts, hashlib.sha256).digest()).decode()
if not hmac.compare_digest(sig, expected):
raise SignatureError("signature mismatch")
# 署名が正しいことを確認してから nonce を記録(SET NX + TTL)
if not nonce_store.add_if_absent(f"{key_id}:{nonce}", ttl=ALLOWED_SKEW * 2):
raise SignatureError("replay detected")
return key_id # 認証済みクライアントの特定に利用10.2 RFC 9421 形式のヘッダ例(Ed25519)
POST /v1/orders?expand=items HTTP/1.1
Host: api.example.com
Content-Type: application/json
Content-Digest: sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:
Signature-Input: sig1=("@method" "@authority" "@path" "@query" "content-type" "content-digest");created=1700000000;expires=1700000300;nonce="b3k2pp5k7z";keyid="client-123-2026a";alg="ed25519"
Signature: sig1=:<base64 encoded signature>:
{"item_id": "abc", "qty": 1}検証側は Signature-Input に @method @authority @path @query content-digest が含まれていること自体を確認し、不足していれば拒否する。
11. チェックリスト
設計
- 既存標準(RFC 9421 / SigV4 / JWS / Webhook 型)を検討した
- アルゴリズムは HMAC-SHA256 以上、または Ed25519 / ECDSA P-256 / RSA-PSS
- メソッド・ホスト・パス・クエリ・ボディダイジェスト・タイムスタンプ・kid を署名対象に含めている
- 正規化ルールが曖昧さなく文書化され、テストベクタがある
リプレイ対策
- タイムスタンプ許容幅を設定し、署名対象に含めている
- Nonce の重複検知がある(副作用のある操作)
- サーバーの時刻同期を監視している
鍵管理
- 鍵は KMS / Secrets Manager / HSM で管理
- クライアント・環境ごとに別鍵
- 複数鍵並行によるローテーションが可能
- 漏洩時の失効手順がある
検証実装
- 定数時間比較を使用
- アルゴリズムをサーバー側で固定
- 生ボディで検証し、検証前にパース・処理しない
- ボディサイズ上限がある
- エラーレスポンスに詳細を出さない
運用
- 検証失敗をカテゴリ別に記録・監視
- 秘密情報・署名値をログに出さない
- SDK とデバッグ用ドキュメントを提供