クラウド Mac で検証する iOS リクエスト署名:HMAC とリプレイ対策の境界
Windows で開発しているクロスプラットフォームアプリにリクエスト署名を組み込むと、通常のリクエストは通るのに、中国語を含むクエリや JSON ボディを持つリクエストだけが失敗することがあります。リモートデスクトップで API 呼び出しを繰り返すよりも、署名モジュールをクラウド Mac 上で単独実行し、まずバイト列の取り決めを検証してから iOS プロジェクトに戻すほうが、原因を切り分けやすくなります。
ここでは Swift と CryptoKit を使い、オフラインで実行できる受け入れテスト用スクリプトを作成します。実際の API、本番用の鍵、署名証明書は不要です。このテストフィクスチャ一式は、クライアント側とサーバー側の両方で保管してください。
まず仕様を決める
HMAC-SHA256 が処理するのはバイト列であり、「見た目が同じ」リクエストではありません。まず、プロトコルバージョン、メソッド、リクエストターゲット、タイムスタンプ、nonce、ボディの SHA-256 ダイジェストという六つのフィールドを定義します。
この例では、メソッドを大文字に固定します。リクエストターゲットにはパスと元のクエリ文字列を含め、フラグメントは含めません。タイムスタンプは秒単位の十進整数とし、nonce はリクエストごとに新しく生成します。ボディのダイジェストは、小文字の十六進文字列で出力します。
クエリパラメータを独自に並べ替えてはいけません。重複するキー、空の値、パーセントエンコーディング、パラメータの順序を同等とみなすかどうかは、API のプロトコルで決める必要があります。クライアントは最終的なエンコードが完了してから署名し、サーバーはフレームワークがパスやクエリパラメータを書き換える前に、合意した表現を取得してください。
HMAC が証明するのは、メッセージと鍵の組み合わせが正しいことです。ボディを暗号化するものではなく、リプレイを自動で防ぐものでもありません。通信の暗号化を代替できず、アプリのパッケージに埋め込んだ共有鍵を、信頼できる認証情報に変えることもできません。
バイト列を固定する
フィールドを単純に改行で連結してはいけません。フィールド自体に改行が含まれると、区切りが曖昧になる可能性があります。この例では、各 UTF-8 フィールドの前に、そのバイト長を表す 8 バイトの符号なしビッグエンディアン整数を付け、続けてフィールドの内容を連結します。サーバー側も、まったく同じ規則を実装する必要があります。
以下を request-signing.swift として保存します。クラウド Mac でまず xcrun swift --version を実行してツールチェーンを確認し、続いて xcrun swift request-signing.swift を実行してください。成功するとスクリプトは正常終了し、アサーションに失敗するとゼロ以外の終了ステータスを返します。
import Foundation
import CryptoKit
func hex<S: Sequence>(_ bytes: S) -> String
where S.Element == UInt8 {
bytes.map { String(format: "%02x", $0) }.joined()
}
func frame(_ fields: [String]) -> Data {
var output = Data()
for field in fields {
let bytes = Data(field.utf8)
var length = UInt64(bytes.count).bigEndian
withUnsafeBytes(of: &length) {
output.append(contentsOf: $0)
}
output.append(bytes)
}
return output
}
func canonical(
target: String,
body: Data,
timestamp: String,
nonce: String
) -> Data {
frame([
"request-v1",
"POST",
target,
timestamp,
nonce,
hex(SHA256.hash(data: body))
])
}
let vectorKey = SymmetricKey(
data: Data(repeating: 0x0b, count: 20)
)
let vector = HMAC<SHA256>.authenticationCode(
for: Data("Hi There".utf8),
using: vectorKey
)
precondition(
hex(vector) ==
"b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"
)
let key = SymmetricKey(data: Data(repeating: 0x42, count: 32))
let body = Data(#"{"count":1}"#.utf8)
let target = "/v1/orders?a=1&b=2"
let timestamp = "1700000000"
let nonce = "fixture-nonce-001"
let message = canonical(
target: target, body: body,
timestamp: timestamp, nonce: nonce
)
let tag = Data(HMAC<SHA256>.authenticationCode(
for: message, using: key
))
func accepts(_ candidate: Data) -> Bool {
HMAC<SHA256>.isValidAuthenticationCode(
tag, authenticating: candidate, using: key
)
}
precondition(accepts(message))
precondition(!accepts(canonical(
target: target, body: Data(#"{"count":2}"#.utf8),
timestamp: timestamp, nonce: nonce
)))
precondition(!accepts(canonical(
target: "/v1/orders?b=2&a=1", body: body,
timestamp: timestamp, nonce: nonce
)))
precondition(!accepts(canonical(
target: target, body: body,
timestamp: timestamp, nonce: "fixture-nonce-002"
)))
固定した鍵、タイムスタンプ、nonce は、フィクスチャ専用です。標準テストベクトルでは暗号アルゴリズムの呼び出しを検証し、その後のアサーションでは、プロトコルの各フィールドが実際に署名に組み込まれているかを検証します。この二種類のテストは、互いの代わりにはなりません。
差異を網羅する
アプリに組み込む際は、送信する最終的なボディの Data を一度だけ生成し、同じものをダイジェストの計算とネットワークリクエストに渡します。署名後に JSON を再エンコードしたり、ミドルウェアがボディを暗黙に差し替えたりしないようにしてください。
エンコードを確認する
中国語と絵文字を含むクエリ、重複するクエリキー、空のボディと空の JSON オブジェクトという三組のフィクスチャを追加します。各組について、フィールドの内容、正規化したバイト列の十六進表現、期待する署名を保存し、クライアントとサーバーでそれぞれ読み込みます。テスト対象の関数を使って、その場で期待値を生成してはいけません。
特に、空文字列、フィールドの欠落、文字列 "null" を区別してください。プロトコルで許可されていない入力は、暗黙にデフォルト値を補うのではなく、HMAC の処理に入る前に拒否します。
転送経路を確認する
リバースプロキシがパスのプレフィックスを書き換える場合や、フレームワークが %20 とプラス記号を同じ空白にデコードする場合があります。外部から届くリクエストターゲットと内部ルートのどちらを署名対象にするかを明確にし、その境界でバイト列を取得してください。
トラブルシューティング時に記録するのは、フィクスチャか、機密情報を除去したダイジェストだけにします。本番のボディ、認証ヘッダー、鍵をビルドログに残してはいけません。鍵をコマンドライン引数で渡すことも避けてください。
リプレイを拒否する
署名の検証に成功しても、サーバーは引き続きタイムスタンプの形式と許容する時刻のずれを確認する必要があります。許容時間枠の長さは業務要件に合わせて決めます。テストでは少なくとも、時間枠内、境界ちょうど、境界をわずかに超えた時刻、未来の時刻という四種類の入力を網羅してください。
続いて「認証情報の識別子+nonce」を重複排除キーとしてアトミックに書き込み、そのリクエストが時刻検証を通過できなくなるまで保持します。検索してから挿入する方式には競合状態があり、同時に届いた二つのリクエストが両方とも通過する可能性があります。アトミックな条件付き書き込み、または一意制約を使う必要があります。
オフラインスクリプトの古いタイムスタンプは、オンラインの受け入れテストには使えません。オンラインのリクエストでは現在時刻を取得し、新しい nonce を生成してください。リトライ時に業務上の冪等性キーを再利用するかどうかは、別途取り決めます。nonce による重複排除を、注文処理の冪等性と同一視してはいけません。
検証を必須チェックに組み込む
まずスクリプトをコミット時のチェックに組み込み、その後、同じフィクスチャをサーバー側のテストにも導入します。ボディの変更に加えて、誤った鍵、途中で切れた署名、不正な署名エンコーディングを拒否するケースも追加してください。受信側では暗号ライブラリの検証関数を使い、通常の文字列の等値比較で代用しないようにします。
最後に、まったく同じ有効なリクエストを二つ同時に送信し、業務処理に進むのが一つだけであることを確認します。さらに、有効時間枠内で正当なリトライを再現し、取り決めどおりの冪等な結果が得られるかを確認してください。
記録として残すのは、ツールチェーンのバージョン、フィクスチャのバージョン、失敗したアサーションの番号だけで十分です。受け入れ完了の基準は「一度リクエストが成功したこと」ではありません。双方が同じバイト列から同じ署名を導き、不正な入力や重複リクエストを、一貫して再現可能な形で拒否できることです。
よくある質問
HMAC が正しければリプレイも防げますか?
防げません。同じ署名付きリクエストは再送できます。サーバーで時刻を検証し、認証情報の識別子と nonce の組を原子的に重複排除します。業務上の再試行には別途、冪等キーを定義します。
JSON を解析してから再エンコードして署名しますか?
この方式では再エンコードしません。送信直前の本文 Data と、サーバーが受信した本文の生バイト列から同じ摘要を計算します。キー順序や空白の変化による不一致を避けられます。