HMAC 請求簽章該簽哪些東西:method、path、query 與 body 的原始 bytes
API 用 HMAC 簽章時,大家都知道要有共享金鑰,也要加時間戳記和 nonce,比較少花時間確認簽章該涵蓋哪些內容。我碰過的平台裡,有的只簽 body,有的先把 body 解析成物件,再序列化一次才驗,兩種都能上線運作,問題卻不會立刻出現。
我的做法是把 method、path、query、body 全部放進 canonical string,body 用收到的原始 bytes 算雜湊,不重新序列化。下面用 Go 1.27.1 把兩種常見錯誤各跑一次,再說明 canonical string 怎麼組;冪等和防重放的分工在〈同一筆付款送了兩次:從 Santander 的聖誕節講冪等與重送〉講過,這篇只談簽章範圍。
一、只簽 body:path 和 query 可以隨便換
只簽 body,通常是把 body 當成業務資料,path 則只負責路由,這個出發點合理,卻漏看了 path 和 query 本身的語意。/credit 和 /debit 代表相反的操作,currency=TWD 和 currency=USD 差了幾十倍。
先準備兩個函式,驗證用 hmac.Equal,標準函式庫文件寫的是「without leaking timing information」;如果用 bytes.Equal 或字串比較,比對時間可能隨相同前綴的長度變化。我在自己的原型裡也修過一次 api key 比對的 timing 問題,這類比較稍不注意就會寫成一般的 ==。
func sign(msg []byte) string {
m := hmac.New(sha256.New, key)
m.Write(msg)
return hex.EncodeToString(m.Sum(nil))
}
func verify(msg []byte, sig string) bool {
want, err := hex.DecodeString(sig)
if err != nil {
return false
}
m := hmac.New(sha256.New, key)
m.Write(msg)
return hmac.Equal(m.Sum(nil), want)
}
原始請求是 POST /v1/wallet/credit?currency=TWD,body 是 {"account":"a1","amount":"100"}。把 query 改成 USD、或把 path 改成 debit,body 一個 byte 都沒動:
POST /v1/wallet/credit?currency=USD body-only=true canonical=false
POST /v1/wallet/debit?currency=TWD body-only=true canonical=false
只簽 body 的版本兩次都驗過了,之前串接過的一個平台就採用這種設計,簽章不涵蓋 path 和 query,後來加報表 API 時,報表種類只能放進已簽的 body 欄位,不能放在 path 上,所有報表共用一個端點。這樣可以運作,路由和存取控制卻無法辨別實際操作,log 裡也看不出來。
二、收到之後重新序列化:合法請求驗不過
另一種錯誤是驗章用的 bytes 和送出時不同,最常見的寫法是 framework 先把 body 解析成 struct,驗章時再用 json.Marshal 轉回去:
raw := []byte(`{"amount":"100.5","account":"a1"}`)
sig := sign(raw)
var c Credit
if err := jsonv1.Unmarshal(raw, &c); err != nil {
panic(err)
}
again, err := jsonv1.Marshal(c)
raw body {"amount":"100.5","account":"a1"} verify=true
re-marshalled {"account":"a1","amount":"100.5"} verify=false
內容雖然一樣,欄位卻改按 struct 的宣告順序排列,雜湊就不同,空白或數字寫法(1e2 和 100)改了也一樣,Unicode 跳脫也會影響結果。
Go 1.27 的標準函式庫已經有 encoding/json/v2,序列化 map 時要留意版本差異,v1 會依 key 排序,v2 預設不排序。拿同一個有 8 個 key 的 map,各序列化 50 次,看看會出現幾種不同輸出:
distinct outputs over 50 runs:
v1 Marshal 1
v2 Marshal 8
v2 Marshal Deterministic 1
v2 那一行的數字每次跑都可能不同,這次是 8 種,加上 Deterministic(true) 之後 map 就會排序。不過文件明確說明,只有同一份 binary 在不同次執行時,才保證輸出一致,換了原始碼、toolchain 版本、GOOS/GOARCH 或 build flag 都不保證。簽章兩端幾乎一定使用不同的 build,甚至不同語言,所以我在八月的設計審查裡訂了規則,canonical string 不准依賴任何 encoder 的輸出順序,body 一律用收到的原始 bytes。
三、canonical string 怎麼組
我用的格式是 method、path、排序後的 query、時間戳記、nonce、body 原始 bytes 的 SHA-256,用換行串起來:
func canonical(method, path, rawQuery, ts, nonce string, body []byte) ([]byte, error) {
q, err := url.ParseQuery(rawQuery)
if err != nil {
return nil, err
}
h := sha256.Sum256(body)
return []byte(strings.Join([]string{
method, path, q.Encode(), ts, nonce, hex.EncodeToString(h[:]),
}, "\n")), nil
}
POST
/v1/wallet/credit
currency=TWD
1791547200
n-7f3a
35dd7c542e6f365b4ea248f71092ae4a8761af211e547487a43c8192d1fad9cf
query 用 url.Values.Encode() 重新編碼,它會依 key 排序,所以參數順序不同的兩個請求會得到同一個 canonical string:
query reordered, same canonical: true
query 是扁平的 key/value,排序和百分比編碼規則可以明訂在規格裡,兩端各自實作也能對得上,因此重新編碼並不違反上一節「不要重新序列化」的原則。JSON body 有巢狀結構,數字格式和空白也得處理,要定義一套兩邊都能完全重現的正規化太難,直接用原始 bytes 最省事。同一個 key 出現多次時,Encode 會保留這些值原本的相對順序:
tag=b&x=1&tag=a -> tag=b&tag=a&x=1
tag=a&x=1&tag=b -> tag=a&tag=b&x=1
兩個請求的參數一樣,canonical string 卻不同,所以規格要寫明重複 key 的值順序也有意義,或乾脆禁止重複 key。
AWS SigV4 走的是同一條路,canonical request 由 HTTP method、URI 編碼過的 path、依 key 排序的 query、簽入的 header、header 名稱清單、payload 的 SHA-256 組成,文件還特別提醒各平台內建的 URI encoder 行為不一定相同,建議自己寫。RFC 9421(HTTP Message Signatures,2024 年 2 月)則把 @method、@path、@query 定義成可簽的元件,body 交給 Content-Digest。兩邊的共同點是 method、path、query 都在簽章範圍內,body 簽的是原始內容的雜湊。
四、重放檢查與金鑰管理
原型的重放檢查用 api key、path 和簽章組成 key,一開始漏了 path,當時的簽章也沒有涵蓋 path,結果同一秒送到不同端點的兩個請求,只要內容相同,簽章就完全一樣,第二個因此被當成重放擋掉。冪等那篇提過 nonce 的範圍要含 path,這裡也看得出來,簽章漏掉 path 會留下竄改空間,也會讓重放檢查誤判。
簽章設計也得考慮金鑰的生命週期,原型裡輪替金鑰時會發新的 secret ref,舊金鑰保留 15 分鐘寬限期,這段時間內兩把都能驗過。建立營運商時,我還遇過先寫入 secret、再插入資料列的問題,api key 重複導致插入失敗,secret 卻已經寫入,把同一個 key 原本的 secret 蓋掉了,所以順序得反過來,資料列插入成功之後才寫 secret。
補充筆記
- 只簽 body 時,path 和 query 可以任意更換,簽章照樣通過;
/credit換成/debit就是相反的操作。 - 驗章用收到的原始 bytes,不要解析後再序列化,欄位順序一改,雜湊就不同,空白和數字寫法也會影響結果。
- Go 1.27 的
encoding/json/v2預設不排序 map key;Deterministic(true)只保證同一份 binary 內的輸出一致,不能當成跨系統的正規化。 - canonical string 包含 method、path、排序後的 query、時間戳記、nonce 和 body 雜湊;AWS SigV4 與 RFC 9421 的範圍和這個一致。
- 比對 MAC 用
hmac.Equal;nonce 去重 key 要含 path;輪替金鑰給寬限期,secret 在資料列寫入成功之後才寫。
延伸閱讀
- AWS 文件〈Create a signed AWS API request〉:SigV4 canonical request 的六個組成與 query 排序、編碼規則。
- RFC 9421〈HTTP Message Signatures〉:IETF 標準,定義
@method、@path、@query等可簽元件,body 透過 RFC 9530 的Content-Digest涵蓋。 - Go 文件〈crypto/hmac〉與〈encoding/json/v2〉:
hmac.Equal的 timing 說明,以及Deterministic只在同一份 binary 內保證一致的原文。 - Wikipedia〈HMAC〉/〈金鑰雜湊訊息鑑別碼〉:HMAC 的定義與構造。
想法與技術判斷出自 Sheng,和 Claude 一起起草 · 範例在 Go 1.27.1 實測。