雲端 Mac 上的 iOS 請求簽章驗收:HMAC、正規化與重送邊界
在 Windows 上開發的跨平台應用程式接上簽章介面後,一般請求可以通過,帶有中文查詢參數或 JSON 本文的請求卻失敗。先把簽章模組放到雲端 Mac 獨立執行,驗證位元組契約後再接回 iOS 專案,比在遠端桌面裡反覆點選介面更容易找出問題。
以下使用 Swift 與 CryptoKit 建立離線驗收腳本。不需要真實介面、正式環境金鑰或簽章憑證;用戶端與伺服器端應共同保存這組測試夾具。
先訂定契約
HMAC-SHA256 處理的是位元組,不是「看起來相同」的請求。先約定六個欄位:協定版本、方法、請求目標、時間戳記、隨機值,以及本文的 SHA-256 摘要。
本例的方法一律使用大寫;請求目標包含路徑與原始查詢字串,不含片段;時間戳記使用十進位整數秒;隨機值則在每次請求時重新產生。本文摘要輸出為小寫十六進位字串。
不要自行排序查詢參數。重複鍵、空值、百分比編碼及參數順序是否視為等價,必須由介面協定決定。用戶端應在最終編碼完成後簽章;伺服器端則應在框架改寫路徑或查詢參數之前,取得雙方約定的表示形式。
HMAC 證明訊息與金鑰相符,不會加密本文,也不會自動阻止重送攻擊。它不能取代傳輸加密,更不能讓內嵌在應用程式套件裡的共用金鑰變成可靠的身分憑證。
固定位元組
不要只是用換行字元串接欄位:欄位本身若含有換行,就可能產生歧義。本例在每個 UTF-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"
)))
固定的金鑰、時間戳記與隨機值僅供測試夾具使用。標準測試向量用來驗證密碼學演算法的呼叫方式,後續斷言則用來驗證協定欄位是否確實納入簽章;這兩類測試不能互相取代。
補齊差異測試
整合至應用程式時,最終本文的 Data 只產生一次,並同時交給摘要計算與網路請求使用。不要在簽章後再次進行 JSON 編碼,也不要讓中介軟體悄悄替換本文。
檢查編碼
新增三組測試夾具:含中文與表情符號的查詢、重複的查詢鍵,以及空本文與空 JSON 物件。每組都保存欄位內容、正規化位元組的十六進位值及預期簽章,由兩端分別讀取;不得用受測函式即時產生預期答案。
務必區分空字串、缺少的欄位與字串 "null"。如果協定不允許某種輸入,就應在進入 HMAC 前拒絕,而不是默默補上預設值。
檢查傳輸
反向代理可能改寫路徑前綴,框架也可能將 %20 與加號解碼為相同的空格。應明確訂定簽章針對的是外部請求目標還是內部路由,並在對應邊界擷取位元組。
排錯時只記錄測試夾具或去識別化後的摘要。正式環境的本文、驗證標頭與金鑰都不應寫入建置日誌;金鑰也不要透過命令列參數傳入。
阻擋重送攻擊
簽章通過後,伺服器端仍須檢查時間戳記的格式與允許偏差。有效時間視窗的長度由業務需求決定,測試至少要涵蓋視窗內、恰好位於邊界、剛超出邊界,以及未來時間這四種輸入。
接著以「憑證識別碼+隨機值」作為去重鍵執行原子寫入,並保留到該請求不可能再通過時間驗證為止。先查詢再新增會有競爭條件:兩個並行請求可能同時通過,因此必須依賴原子條件寫入或唯一性約束。
離線腳本裡的舊時間戳記不能用於線上驗收。線上請求應讀取目前時間,並產生新的隨機值。重試時是否沿用業務冪等鍵,需要另外約定,不能把隨機值去重當成訂單冪等性。
納入驗收關卡
先將腳本納入提交檢查,再把同一組測試夾具接入伺服器端測試。除了本文變更,還要新增使用錯誤金鑰、截斷簽章及非法簽章編碼時的拒絕案例。接收端應使用密碼學函式庫的驗證函式,不要以一般的字串相等比較取代。
最後並行送出兩份完全相同的有效請求,確認只有一份進入業務處理;再模擬有效時間視窗內的合法重試,檢查是否取得約定的冪等結果。
只需保留工具鏈版本、測試夾具版本及失敗斷言編號。驗收完成的標準不是「一次請求成功」,而是雙方對同一組位元組算出相同簽章,並對非法輸入與重複請求給出一致、可重現的拒絕結果。
常見問題
HMAC 驗證通過,就能阻止重複請求嗎?
不能。完整的有效請求仍可再次送出。伺服器必須檢查時間容許範圍,並以憑證識別碼與隨機值的組合進行原子去重;業務重試則需要另外定義冪等鍵。
簽章前應該先解析 JSON 再重新編碼嗎?
本方案不重新編碼。用戶端對最後送出的本文 Data 計算摘要,伺服器則處理收到的原始位元組,避免鍵順序、空白與數字格式改變而造成驗證失敗。