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 在資料列寫入成功之後才寫。

延伸閱讀

想法與技術判斷出自 Sheng,和 Claude 一起起草 · 範例在 Go 1.27.1 實測。