在 Java 中,HMAC 秘密金鑰(HMAC secret key)就是傳遞給 SecretKeySpec 的原始位元組序列,以便 javax.crypto.Mac 引擎可以產生金鑰雜湊訊息鑑別碼(keyed-hash message authentication code)。HMAC 本身不是加密演算法;它結合了一個密碼學雜湊(SHA-256、SHA-384 或 SHA-512)與一個共用秘密,產生一個固定長度的標籤(tag),讓驗證者能同時確認訊息的完整性(integrity)與真實性(authenticity)。在 Java 中,你需在 Mac 實例上設定演算法(HmacSHA256、HmacSHA384、HmacSHA512),並將與驗證者完全相同的位元組傳遞給它。雜湊演算法決定了標籤的長度:SHA-256 會回傳 32 位元組,SHA-384 會回傳 48 位元組,SHA-512 會回傳 64 位元組,標籤慣例上會以小寫十六進位或標準填補的 Base64 表示。因為金鑰的每個位元組以及訊息的每個位元組都會餵入雜湊運算,所以編碼方式的選擇(UTF-8 文字 vs 十六進位位元組)以及任何夾帶的空白字元,都可能在無聲無息中產生不同的標籤。因此,若要在兩端一致地產生與驗證 HMAC 標籤,必須先明確固定演算法、金鑰位元組以及訊息位元組各一次,然後在兩端完全重現這些設定。

什麼是 Java 中的 HMAC 秘密金鑰?
在 Java Cryptography Architecture 中,任何用 javax.crypto.spec.SecretKeySpec 包裝的位元組陣列都可以作為 HMAC 秘密金鑰。這個類別(class)並不會幫你產生金鑰;它只是把這些位元組標記上一個演算法名稱,好讓 Mac.getInstance(...) 能夠接受它們。真正的金鑰素材(key material)必須來自具有足夠熵(entropy)的來源:一個為相同 HMAC 演算法設定的 KeyGenerator、一段 SecureRandom 位元組序列,或與驗證者透過離線方式協議好的位元組。在 JVM 內部,Java 的 KeyGenerator.getInstance("HmacSHA256") 初始化為 256 位元(32 位元組)的金鑰長度,是取得 HMAC-SHA-256 新金鑰的標準作法;若改用 HmacSHA384 進行相同的呼叫,則會回傳 384 位元的金鑰素材;HmacSHA512 則會回傳 512 位元的素材。
人類可讀的密碼(password)並不會自動成為強健的 HMAC 金鑰。如果協定(protocol)要求把密碼轉換成金鑰,就必須使用基於密碼的 KDF(PBKDF2、scrypt、Argon2,或規範中明確指定的特定演算法),並採用協定所宣告的精確 salt(鹽值)、迭代次數(iteration count)與長度。如果只是隨手把密碼雜湊(hash)一下或截斷一部分,產生的金鑰看起來似乎合理,卻會破壞威脅模型(threat model);因為只有當參數是針對該輸入類別挑選時,低熵(low-entropy)的輸入才能安全地通過 KDF。
一旦位元組備妥,new SecretKeySpec(keyBytes, "HmacSHA256") 就會把這些位元組交給 Mac。SecretKeySpec 上的演算法字串必須與傳遞給 Mac.getInstance(...) 的演算法字串相符,因為 Mac 引擎會拒絕不一致的組合。兩個不同的 Java 程式只要都在相同的位元組上呼叫 HmacSHA256,就會產生相同的標籤,而這份一致性正是驗證者唯一需要的事情。
從瀏覽器產生 HMAC 標籤
這是最快速的產生參考標籤、並檢查你的 Java 程式輸出的方式。請開啟 HMAC Generator,按照協定定義的方式輸入金鑰與訊息,然後以接收端所預期的編碼格式複製呈現出來的標籤。
- 選擇 SHA-256、SHA-384 或 SHA-512,並確認該協定或規範(specification)所預期的是該精確長度的完整 HMAC 標籤(分別為 32、48 或 64 位元組)。
- 針對金鑰與訊息,獨立選擇 UTF-8 或十六進位模式。文字模式(Text mode)會將欄位編碼為 UTF-8 位元組,因此帶有變音符號的字母、CJK 字元以及表情符號(emoji)會佔用多個位元組;十六進位模式(hex mode)則僅接受偶數個十六進位數字,並完整保留每個位元組(包含零值位元組)。
- 輸入精確的位元組,不要添加額外的格式。請勿貼上前綴(例如 0x),也不要插入空格、冒號或換行,更不要讓編輯器自動換行(auto-wrap)過長的內容。十六進位模式會拒絕前綴、空格、冒號以及奇數的 nibble,這樣一來,意外輸入的格式字元就不會悄悄改變協定中的數值。
- 產生標籤,並以小寫十六進位或標準填補的 Base64 複製它。十六進位與 Base64 只是同一份標籤位元組的兩種呈現方式;請依接收系統預期的編碼擇一使用。
- 只有在協定規範明確要求時,才進行轉換或截斷。有些協定會使用 Base64url 而非標準 Base64,有些會使用截斷的標籤(例如 HMAC-SHA-256 的前 16 位元組),也有些會在前面加上演算法識別碼。這些規則都必須以協定文件為準,而非憑肉眼判斷。
由於整個運算是透過 瀏覽器的 Web Cryptography API 執行,並不會把輸入資料傳送到網站伺服器,因此你可以直接貼上本地的測試向量(test vectors)而不會洩漏它們。網頁隨時都會顯示完整的標籤;如果輸出結果看起來比該 SHA 變體應有的長度還短,那是因為輸入在簽章前就被拒絕,而非被悄悄截斷。
Java 程式碼參考:javax.crypto.Mac
Java 的參考實作路徑使用了 javax.crypto.Mac 與 javax.crypto.spec.SecretKeySpec。三種 SHA 變體的程式碼骨架完全相同;只有演算法名稱以及最終標籤的長度不同。當你以 String 建構位元組陣列時,getBytes(StandardCharsets.UTF_8) 是唯一能與瀏覽器工具的 UTF-8 模式相匹配的編碼方式。若使用平台預設字元集(platform default charset),會在不同作業系統上悄悄產生不同的位元組,而這正是開發環境與正式環境之間發生 HMAC 結果不一致的經典原因之一。
以 RFC 4231 中已發佈的測試向量(Test Case 1 for HMAC-SHA-256)為例,其金鑰為 20 個位元組的 0x0b,而資料則是 ASCII 字串 Hi There。HMAC-SHA-256 的標籤即為下列 32 位元組的十六進位字串:b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7。如果你的 Java 程式與瀏覽器工具在這些精確的位元組下都能產生完全相同的十六進位結果,那麼演算法、金鑰位元組與訊息編碼三項就完全一致;若結果不一致,問題就出在這三個環節中的某處,而非雜湊演算法本身。
驗證路徑會呼叫 Mac.doFinal(messageBytes),然後將結果與傳送端送來的標籤進行比對,比對時必須使用常數時間(constant-time)的比較方式(即對兩個位元組陣列使用 MessageDigest.isEqual),避免因執行時間的差異而洩漏出正確前綴(correct prefix)的相關資訊。直接使用 Arrays.equals 會洩漏時序資訊;伺服器端請務必使用常數時間的檢查方式。
金鑰位元組、編碼與雜湊:影響標籤的因素
有四個相互關聯的選擇決定了兩次 HMAC 計算結果是否一致。即使演算法名稱仍然是「HMAC-SHA-256」,只要改動其中任何一個,都會悄悄產生不同的標籤。
| 選擇 | 對標籤的影響 |
|---|---|
| 雜湊變體 | SHA-256 會回傳 32 位元組;SHA-384 會回傳 48 位元組;SHA-512 會回傳 64 位元組。 |
| 金鑰編碼 | UTF-8 文字與十六進位位元組只有在瀏覽器工具的輸入選擇一致時,才會代表同一個邏輯金鑰。一個 3 位元組的 ASCII 金鑰與其十六進位的呈現方式,會產生不同的位元組,進而產生不同的標籤。 |
| 訊息編碼 | UTF-8 會把帶有變音符號的字母、CJK 字元與表情符號編碼成多位元組序列。十六進位模式則會逐字元保留每個位元組,包括零值位元組。換行字元(newline)、歸位字元(carriage return)以及尾端空白都必須完全一致。 |
| 標籤編碼 | 小寫十六進位與標準填補的 Base64 是同一份位元組的不同呈現方式。Base64url(無填補、使用 - 與 _)與標準 Base64 並不可互換;若解碼了錯誤的格式,就會得到不同的位元組。 |
當兩套系統比對結果不一致時,請依序檢視此表格:先確認雜湊,再確認金鑰位元組,再確認訊息位元組,最後才檢查標籤編碼。大多數的不一致發生在前三列,而非呈現方式本身。
造成標籤不一致的常見錯誤
當看到「同一份輸入,產生兩個不同標籤」這個相同的症狀時,幾乎都能追溯到以下幾種錯誤中的某一種。把它們當作檢查清單(checklist),可以加速除錯。
- 雜湊漂移(Hash drift)。簽署端選用 SHA-256,但驗證端卻假設為 SHA-384,或反過來。在還沒檢查編碼之前,標籤長度就已經對不上了。
- 金鑰漂移(Key drift)。兩端雖然同意使用同一組密碼,但其中一端使用了不同的 KDF、不同的 salt 或不同的迭代次數。即使人類可讀的密碼看似相同,原始的金鑰位元組卻不相同。
- 訊息漂移(Message drift)。日誌層(logging layer)在訊息末端加上了換行字元;JSON 序列化器(serializer)跳脫(escape)了某個字元;或是把 UTF-8 與 Latin-1 混為一談。上述任何一種情況,都會改變雜湊所看到的位元組流。
- 編碼漂移(Encoding drift)。一端使用十六進位,另一端使用 Base64url,但協定原本要求的是標準 Base64。解碼了錯誤的格式就會得到不同的位元組,驗證端就會失敗。
- 標籤截斷(Truncated tags)。若協定確實允許使用截斷後的標籤,必須在規範中明文載明。單純「憑目測」修剪成一個漂亮的字元數,只會產生一個被驗證端拒絕的標籤。
上述每一種錯誤都是可以回復的:先確定協定規範、鎖定演算法、鎖定金鑰位元組、鎖定訊息位元組,再使用規範中指定的呈現方式。HMAC Generator 提供了上述三種雜湊與兩種輸入編碼,因此你可以重現每一種可能的解讀方式,並觀察哪一種能夠對得上。
瀏覽器工具勝過 Java 程式碼的時機
Java 程式碼才是正式環境中執行簽署與驗證的正確場域,因為 JVM 會在行程記憶體(process memory)中持有秘密,並與你的金鑰儲存區(key store)與存取控制(access control)整合在一起。瀏覽器工具則在以下兩個特定情境中能發揮價值。
第一個情境,是當你在除錯標籤不一致的問題時。與其從正式環境的程式碼中印出中間的位元組陣列,不如將已發佈的測試向量(test vectors)貼到 HMAC Generator 中,確認它能重現 RFC 4231 的結果。該頁面已內建八組完整的 RFC 4231 一致性標籤(conformance tags),涵蓋短金鑰、重複位元組的二進位金鑰與資料、比摘要長度還短的金鑰,以及跨越雜湊區塊(hash block)邊界的資料。如果該工具能對應上 RFC,但你的 Java 程式卻不行,那麼錯誤就出在你的金鑰或訊息位元組;如果該工具也無法對應上 RFC,則代表你輸入的內容與你所想的不同。
第二個情境,是當你需要與同事分享某個參考標籤,或將其貼入 Postman 請求時。由於 Web Cryptography API 完全在當前的分頁(tab)中執行,且頁面絕不會將輸入送到伺服器,你可以直接在筆電上重現貼近正式環境的標籤,而無須架設 Java 環境。每個解碼後的欄位上限為 1,000,000 位元組,足以涵蓋任何實際的協定訊息;而空白的金鑰或訊息會被拒絕,因此意外點擊並不會產生誤導性的零長度標籤。
除此之外,請將簽署路徑保留在 Java 中,如此一來秘密便不會離開 JVM,伺服器端也容易進行常數時間的比較。請將瀏覽器工具視為參考用的計算機,而非部署的介面。
如果你正在權衡各種方案,How to Generate an RSA Key for a Cisco Switch 一文對此有詳細的說明。
如果你正在權衡各種方案,How to Generate an HMAC-SHA256 Signature: Byte-Exact Steps 一文對此有詳細的說明。