<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Hmac on Tarragon</title><link>https://tarrragon.github.io/blog/tags/hmac/</link><description>Recent content in Hmac on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Wed, 29 Jul 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/hmac/index.xml" rel="self" type="application/rss+xml"/><item><title>7.34 機器憑證的機制選型：秘密要不要在每次呼叫裡送出去</title><link>https://tarrragon.github.io/blog/backend/07-security-data-protection/machine-credential-mechanism-selection/</link><pubDate>Wed, 29 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/07-security-data-protection/machine-credential-mechanism-selection/</guid><description>&lt;p>決定用哪一種機器憑證機制時，有一項成本在當下算不出來：選定的機制要什麼配套才跑得起來。那一項在選型會議上通常被算成零，而它一年後才到期。&lt;/p>
&lt;p>選型要回答兩個問題，而它們的交集決定機制：這個秘密會不會在每次呼叫裡送出去、以及撤銷一次要影響幾個呼叫方。兩題之前還有一個前提要先確認——選擇權在誰。對方的 API 文件寫什麼就是什麼的整合，與自己定介面的整合，能做的判斷完全不同。&lt;/p>
&lt;h2 id="本章涵蓋與不涵蓋">本章涵蓋與不涵蓋&lt;/h2>
&lt;p>本章聚焦系統層那一把憑證的機制選擇。呼叫方身分該不該分層、分幾層在 &lt;a href="../api-authentication-trust-boundaries/">7.29 API 認證的信任邊界分層&lt;/a>——那是本章的上游，機制選型只在確定系統層要有獨立身分之後才發生。選定之後憑證怎麼核發與交付在 &lt;a href="../machine-credential-issuance/">7.32 機器憑證的配發&lt;/a>，上線後的輪替與回收在 &lt;a href="../secrets-and-machine-credential-governance/">7.6 秘密管理與機器憑證治理&lt;/a>。各機制的實作細節（雙密過渡怎麼做、mTLS 的 nginx 設定、簽章素材怎麼對齊）在對應的實作文章，本章給的是選哪一個與為什麼。&lt;/p>
&lt;h2 id="本章-threat-scope">本章 threat scope&lt;/h2>
&lt;p>&lt;strong>In-scope&lt;/strong>：秘密隨請求送出而落在日誌與中繼 / 撤銷粒度與呼叫方數量不匹配 / 機制要求的基礎建設沒有跟上 / 換發憑證的那一跳成為單點。&lt;/p>
&lt;p>&lt;strong>Out-of-scope&lt;/strong>（路由到他章）：&lt;/p>
&lt;ul>
&lt;li>呼叫方身分要分幾層 → &lt;a href="../api-authentication-trust-boundaries/">7.29&lt;/a>&lt;/li>
&lt;li>選定之後憑證怎麼交到對方手上 → &lt;a href="../machine-credential-issuance/">7.32&lt;/a>&lt;/li>
&lt;li>上線後的輪替、回收與事件收斂 → &lt;a href="../secrets-and-machine-credential-governance/">7.6&lt;/a>&lt;/li>
&lt;li>這個機制屬於哪一類密碼學原語、金鑰放哪裡 → &lt;a href="../cryptographic-primitive-selection/">7.28&lt;/a>&lt;/li>
&lt;li>憑證的信任鏈與續期節奏 → &lt;a href="../transport-trust-and-certificate-lifecycle/">7.5&lt;/a>&lt;/li>
&lt;li>一個系統代表某個特定的人 → &lt;a href="../delegated-credential-selection/">7.33&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>out-of-scope 的議題直接跳到對應章節。&lt;/p>
&lt;h2 id="從本章到實作">從本章到實作&lt;/h2>
&lt;p>本章是 routing layer，沿兩條 chain 進入 implementation：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Mechanism&lt;/strong>：問題節點表「前置控制面」欄的連結進知識卡，看該控制的機制、邊界與適用條件。&lt;/li>
&lt;li>&lt;strong>Delivery&lt;/strong>：「交接路由」欄位指向 &lt;a href="https://tarrragon.github.io/blog/backend/04-observability/" data-link-title="模組四：可觀測性平台" data-link-desc="整理 log、metric、trace、dashboard 與 alert 的後端操作實務">04 可觀測性&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/05-deployment-platform/" data-link-title="模組五：部署平台與網路入口" data-link-desc="整理 Kubernetes、systemd、load balancer、container 與服務生命週期合約">05 部署平台&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/06-reliability/" data-link-title="模組六：可靠性驗證流程" data-link-desc="用 SRE 領域詞彙建問題節點、以服務級案例庫累積驗證脈絡，先建概念與案例庫再進實作交接">06 可靠性&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/08-incident-response/" data-link-title="模組八：事故處理與復盤" data-link-desc="用 IR 領域詞彙建問題節點、以服務級案例庫累積事故脈絡，先建概念與案例庫再進實作交接">08 事故處理&lt;/a>。本章有一列走 04，因為「秘密落進紀錄」的偵測與遮罩落在那一層。&lt;/li>
&lt;/ul>
&lt;p>兩條 chain 完成判準與模組級 chain 規格見 &lt;a href="../#%e5%be%9e%e7%ab%a0%e7%af%80%e5%88%b0%e5%af%a6%e4%bd%9c%e7%9a%84-chain">從章節到實作的 chain&lt;/a>。&lt;/p>
&lt;h2 id="先確認自己有沒有選擇權">先確認自己有沒有選擇權&lt;/h2>
&lt;p>判別式是&lt;strong>誰寫整合規格&lt;/strong>，不是誰是伺服器那一端。這兩者常常一致而不總是一致：接第三方的 webhook 時被呼叫的是自己的 API，而機制由對方的文件決定；提供平台給大量客戶接的一方即使一直在被呼叫，規格也是自己寫的。判錯這一題會讓後面兩問白做。&lt;/p>
&lt;p>規格由對方寫時，能用的機制由對方提供。規格由自己寫時選擇權在自己，但每一個既有的呼叫方都要跟著改。&lt;strong>既有介面換機制的邊界，是能不能同時接受新舊兩種機制並訂出落日期。&lt;/strong> 呼叫方兩百個一樣換得掉，只是要一年；做不到就只剩一次性切換，而那需要全體同時配合——這一支本站尚無專章，最小做法是把它當成一次對外的破壞性變更來排：先公告期限、期間新舊都收、到期日之後才關掉舊的，通用紀律見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/backward-compatibility-discipline/" data-link-title="11.6 向後相容的變更紀律" data-link-desc="哪些變更算 breaking、相容性檢查放人工還是 CI、檢查粒度怎麼選 — 讓介面變更可審可擋的日常紀律">11.6 向後相容的變更紀律&lt;/a>。&lt;/p>
&lt;p>沒有選擇權時本章其餘各節仍然有用，但用途不同：它給的是「照對方的方式接上去之後，我這邊還缺哪一塊」。對方只給一把長期 API key 時，暴露面與撤銷粒度都由那個選擇決定，自己能做的是在自己這一側補上日誌遮罩與存取隔離，並把差額記成已知風險。對方要求用它自己的 SDK 時連這一步都做不到——機制被封裝、看不見也遮罩不了，能留下的只有「這條整合的暴露面由對方的實作決定」這句紀錄，以及把它排進定期重評估。&lt;/p>
&lt;p>&lt;strong>雙向整合的兩個方向各跑一次。&lt;/strong> 我打對方用一把、對方回呼我用另一把，這是接第三方服務的標準形態，而兩個方向的規格作者、暴露面與撤銷路徑各自獨立。下方的判讀流程與四格表都是單向的，兩個方向要各走一遍、得到兩組答案。&lt;/p>
&lt;h2 id="兩個問題把選項分開">兩個問題把選項分開&lt;/h2>
&lt;p>先分開兩個詞：&lt;strong>憑證&lt;/strong>是這條整合的身分整體，&lt;strong>秘密&lt;/strong>是其中不能外流的那一部分——共享密鑰的密鑰本身、mTLS 的私鑰。撤銷處理的是憑證，送不送出去說的是秘密。（&lt;a href="../secrets-and-machine-credential-governance/">7.6&lt;/a> 的類型分層用的是另一套切法，那裡的「部署憑證」是與 secret、token、key 並列的四類之一。）&lt;/p>
&lt;p>&lt;strong>問題一：這個秘密本身要不要在每次呼叫裡送出去。&lt;/strong> 這一題決定它最後會留在哪些地方。&lt;/p>
&lt;p>送出去的機制（共享密鑰、API key、client credentials 換 token 的那一次）把秘密交給整條傳輸路徑上的每一跳處理，而那些跳點各自有自己的記錄行為——反向代理的存取日誌、內容傳遞網路的邊緣節點、應用層的請求追蹤、以及對方那一側的同一組系統。秘密沒有洩漏，只是被記錄了，而記錄的保存期限與可讀範圍由那些系統各自的設定決定。放進網址參數是這一格最嚴重的形態，因為多數伺服器的預設存取日誌格式會完整記錄請求行、含查詢字串；放進 header 好一些，但請求追蹤與錯誤回報工具可能會抓 header——這一項各家預設不同，要逐一翻設定。兩者都查得到：翻一次自己的日誌格式設定與追蹤工具的欄位設定就知道。&lt;/p>
&lt;p>不送出去的機制（共享密鑰簽章、mTLS）只把秘密用來運算：網路上流的是簽章值或握手結果，秘密本身留在兩端的記憶體裡。代價是兩邊都要實作運算邏輯，而排錯比「比對一個字串」難得多——失敗時只會得到「對不起來」，看不出是哪一個欄位不一致。&lt;/p>
&lt;p>下方表格用「&lt;strong>送得起&lt;/strong>」指這一題的判定結果：那些跳點的紀錄保存期限與可讀範圍查得出來、而且查出來的答案可以接受。查不出來就當作送不起。&lt;/p>
&lt;p>跳點全在自己掌控之內、日誌設定翻得到而且沒問題時，這一題五秒鐘就答完，決策整個落在第二題與基礎建設那一欄。純內網的服務之間互相呼叫多半是這種情形。&lt;/p>
&lt;p>&lt;strong>問題二：撤銷一把憑證要影響幾個呼叫方。&lt;/strong> 這一題決定事件當天的處置範圍。&lt;/p>
&lt;p>共享密鑰是一把服務全部，撤銷等於中斷整條整合，這條約束的實際份量見 &lt;a href="../api-authentication-trust-boundaries/#%e8%ba%ab%e5%88%86%e7%b6%ad%e5%ba%a6%e5%88%86%e5%b1%a4%e6%a8%a1%e5%9e%8b">7.29 身分維度分層模型&lt;/a> —— 該節中段列出系統層的撤銷由哪些事件觸發、以及為什麼同一個「立刻撤銷」的需求在這一層要用天或週計算。API key 與 mTLS 憑證是一個呼叫方一把，撤銷影響單一對象——而 API key 的這個能力來自那張登記表，不是來自憑證本身。粒度細到什麼程度是設計選擇，不是機制給定的：共享密鑰也可以每個對象發一把，代價是要維護那張表，而那正是 API key 這個名字實際指的東西。&lt;/p>
&lt;h2 id="兩題交叉之後的落點">兩題交叉之後的落點&lt;/h2>
&lt;p>兩個問題各自有兩個答案，交叉出四格，每一格有對應的機制：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>&lt;/th>
 &lt;th>&lt;strong>只有一個呼叫方&lt;/strong>&lt;/th>
 &lt;th>&lt;strong>有多個呼叫方&lt;/strong>&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;strong>秘密送得起&lt;/strong>&lt;/td>
 &lt;td>共享密鑰&lt;/td>
 &lt;td>API key（要有登記表）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>秘密送不起&lt;/strong>&lt;/td>
 &lt;td>共享密鑰簽章&lt;/td>
 &lt;td>每個呼叫方一把簽章密鑰，或 mTLS&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>右下那一格是實際上最常見的組合（同時接多個外部推送提供方、又不想讓密鑰進入日誌），而它的兩個選項成本差距在配套而非在密鑰數量：每方一把簽章密鑰要的是與 API key 同一張登記表，mTLS 要的是一整套憑證的簽發、續期與撤銷。前者是一張表、成本隨整合數變動，後者是一套基礎建設、多半是固定成本。所以整合數少時 mTLS 貴、共用同一個 CA 的整合夠多時成本結構會翻轉，攤提軸的算法見 &lt;a href="../machine-credential-issuance/#%e9%85%8d%e7%99%bc%e6%98%af%e5%8f%af%e4%bb%a5%e8%a2%ab%e6%b6%88%e9%99%a4%e7%9a%84%e5%8b%95%e4%bd%9c">7.32 的信任錨交叉點&lt;/a>。&lt;/p>
&lt;p>下表展開這些機制各自的性質。四格與下表的列&lt;strong>不是一對一&lt;/strong>——共享密鑰簽章那一列同時承擔左下與右下兩格，差別在一把共用還是每方一把；右下那一格的另一個選項才是 mTLS。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>機制&lt;/th>
 &lt;th>秘密要不要送出去&lt;/th>
 &lt;th>撤銷一次影響幾個呼叫方&lt;/th>
 &lt;th>要有的配套&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>共享密鑰&lt;/td>
 &lt;td>要&lt;/td>
 &lt;td>共用該密鑰的全部&lt;/td>
 &lt;td>無&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>API key&lt;/td>
 &lt;td>要&lt;/td>
 &lt;td>單一（靠登記表達成）&lt;/td>
 &lt;td>憑證與呼叫方的登記表、發放介面&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>共享密鑰簽章&lt;/td>
 &lt;td>不要&lt;/td>
 &lt;td>共用該密鑰的全部；每方一把時為單一&lt;/td>
 &lt;td>兩端的簽章實作與素材規格&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>mTLS&lt;/td>
 &lt;td>不要&lt;/td>
 &lt;td>單一&lt;/td>
 &lt;td>CA、簽發與續期、撤銷清單或線上查詢&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>真正決定撤銷粒度的不是機制的名字，是有沒有一張把憑證對應到呼叫方的表。&lt;/strong> 共享密鑰與 API key 在密碼學上是同一件事——都是持有即通過、都做等值比對——差別只在那張表。有表，撤一把只影響一個；沒有，撤一把影響全部。名字本身靠不住：有服務把全帳號共用的靜態 token 叫 API key、也有服務按夥伴發放並登記卻叫它 shared secret。所以「我們改用 API key」這句話在團隊裡要講清楚實質內容是建那張表，否則會被聽成改個 header 名字。&lt;/p>
&lt;p>這張表也可以不由自己維護：雲平台派發的身分（實例掛載的角色、容器編排的服務帳號）粒度同樣是單一，而對應關係在平台那一側，自己這邊看得到卻改不了。這條路徑不在上表裡，它的判讀走 &lt;a href="../workload-identity-and-federated-trust/">7.10 Workload Identity 與聯邦信任邊界&lt;/a>，而它能不能用是 &lt;a href="../machine-credential-issuance/#%e9%85%8d%e7%99%bc%e6%98%af%e5%8f%af%e4%bb%a5%e8%a2%ab%e6%b6%88%e9%99%a4%e7%9a%84%e5%8b%95%e4%bd%9c">7.32 的第一題&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>決定用哪一種機器憑證機制時，有一項成本在當下算不出來：選定的機制要什麼配套才跑得起來。那一項在選型會議上通常被算成零，而它一年後才到期。</p>
<p>選型要回答兩個問題，而它們的交集決定機制：這個秘密會不會在每次呼叫裡送出去、以及撤銷一次要影響幾個呼叫方。兩題之前還有一個前提要先確認——選擇權在誰。對方的 API 文件寫什麼就是什麼的整合，與自己定介面的整合，能做的判斷完全不同。</p>
<h2 id="本章涵蓋與不涵蓋">本章涵蓋與不涵蓋</h2>
<p>本章聚焦系統層那一把憑證的機制選擇。呼叫方身分該不該分層、分幾層在 <a href="../api-authentication-trust-boundaries/">7.29 API 認證的信任邊界分層</a>——那是本章的上游，機制選型只在確定系統層要有獨立身分之後才發生。選定之後憑證怎麼核發與交付在 <a href="../machine-credential-issuance/">7.32 機器憑證的配發</a>，上線後的輪替與回收在 <a href="../secrets-and-machine-credential-governance/">7.6 秘密管理與機器憑證治理</a>。各機制的實作細節（雙密過渡怎麼做、mTLS 的 nginx 設定、簽章素材怎麼對齊）在對應的實作文章，本章給的是選哪一個與為什麼。</p>
<h2 id="本章-threat-scope">本章 threat scope</h2>
<p><strong>In-scope</strong>：秘密隨請求送出而落在日誌與中繼 / 撤銷粒度與呼叫方數量不匹配 / 機制要求的基礎建設沒有跟上 / 換發憑證的那一跳成為單點。</p>
<p><strong>Out-of-scope</strong>（路由到他章）：</p>
<ul>
<li>呼叫方身分要分幾層 → <a href="../api-authentication-trust-boundaries/">7.29</a></li>
<li>選定之後憑證怎麼交到對方手上 → <a href="../machine-credential-issuance/">7.32</a></li>
<li>上線後的輪替、回收與事件收斂 → <a href="../secrets-and-machine-credential-governance/">7.6</a></li>
<li>這個機制屬於哪一類密碼學原語、金鑰放哪裡 → <a href="../cryptographic-primitive-selection/">7.28</a></li>
<li>憑證的信任鏈與續期節奏 → <a href="../transport-trust-and-certificate-lifecycle/">7.5</a></li>
<li>一個系統代表某個特定的人 → <a href="../delegated-credential-selection/">7.33</a></li>
</ul>
<p>out-of-scope 的議題直接跳到對應章節。</p>
<h2 id="從本章到實作">從本章到實作</h2>
<p>本章是 routing layer，沿兩條 chain 進入 implementation：</p>
<ul>
<li><strong>Mechanism</strong>：問題節點表「前置控制面」欄的連結進知識卡，看該控制的機制、邊界與適用條件。</li>
<li><strong>Delivery</strong>：「交接路由」欄位指向 <a href="/blog/backend/04-observability/" data-link-title="模組四：可觀測性平台" data-link-desc="整理 log、metric、trace、dashboard 與 alert 的後端操作實務">04 可觀測性</a>、<a href="/blog/backend/05-deployment-platform/" data-link-title="模組五：部署平台與網路入口" data-link-desc="整理 Kubernetes、systemd、load balancer、container 與服務生命週期合約">05 部署平台</a>、<a href="/blog/backend/06-reliability/" data-link-title="模組六：可靠性驗證流程" data-link-desc="用 SRE 領域詞彙建問題節點、以服務級案例庫累積驗證脈絡，先建概念與案例庫再進實作交接">06 可靠性</a>、<a href="/blog/backend/08-incident-response/" data-link-title="模組八：事故處理與復盤" data-link-desc="用 IR 領域詞彙建問題節點、以服務級案例庫累積事故脈絡，先建概念與案例庫再進實作交接">08 事故處理</a>。本章有一列走 04，因為「秘密落進紀錄」的偵測與遮罩落在那一層。</li>
</ul>
<p>兩條 chain 完成判準與模組級 chain 規格見 <a href="../#%e5%be%9e%e7%ab%a0%e7%af%80%e5%88%b0%e5%af%a6%e4%bd%9c%e7%9a%84-chain">從章節到實作的 chain</a>。</p>
<h2 id="先確認自己有沒有選擇權">先確認自己有沒有選擇權</h2>
<p>判別式是<strong>誰寫整合規格</strong>，不是誰是伺服器那一端。這兩者常常一致而不總是一致：接第三方的 webhook 時被呼叫的是自己的 API，而機制由對方的文件決定；提供平台給大量客戶接的一方即使一直在被呼叫，規格也是自己寫的。判錯這一題會讓後面兩問白做。</p>
<p>規格由對方寫時，能用的機制由對方提供。規格由自己寫時選擇權在自己，但每一個既有的呼叫方都要跟著改。<strong>既有介面換機制的邊界，是能不能同時接受新舊兩種機制並訂出落日期。</strong> 呼叫方兩百個一樣換得掉，只是要一年；做不到就只剩一次性切換，而那需要全體同時配合——這一支本站尚無專章，最小做法是把它當成一次對外的破壞性變更來排：先公告期限、期間新舊都收、到期日之後才關掉舊的，通用紀律見 <a href="/blog/backend/11-api-design/backward-compatibility-discipline/" data-link-title="11.6 向後相容的變更紀律" data-link-desc="哪些變更算 breaking、相容性檢查放人工還是 CI、檢查粒度怎麼選 — 讓介面變更可審可擋的日常紀律">11.6 向後相容的變更紀律</a>。</p>
<p>沒有選擇權時本章其餘各節仍然有用，但用途不同：它給的是「照對方的方式接上去之後，我這邊還缺哪一塊」。對方只給一把長期 API key 時，暴露面與撤銷粒度都由那個選擇決定，自己能做的是在自己這一側補上日誌遮罩與存取隔離，並把差額記成已知風險。對方要求用它自己的 SDK 時連這一步都做不到——機制被封裝、看不見也遮罩不了，能留下的只有「這條整合的暴露面由對方的實作決定」這句紀錄，以及把它排進定期重評估。</p>
<p><strong>雙向整合的兩個方向各跑一次。</strong> 我打對方用一把、對方回呼我用另一把，這是接第三方服務的標準形態，而兩個方向的規格作者、暴露面與撤銷路徑各自獨立。下方的判讀流程與四格表都是單向的，兩個方向要各走一遍、得到兩組答案。</p>
<h2 id="兩個問題把選項分開">兩個問題把選項分開</h2>
<p>先分開兩個詞：<strong>憑證</strong>是這條整合的身分整體，<strong>秘密</strong>是其中不能外流的那一部分——共享密鑰的密鑰本身、mTLS 的私鑰。撤銷處理的是憑證，送不送出去說的是秘密。（<a href="../secrets-and-machine-credential-governance/">7.6</a> 的類型分層用的是另一套切法，那裡的「部署憑證」是與 secret、token、key 並列的四類之一。）</p>
<p><strong>問題一：這個秘密本身要不要在每次呼叫裡送出去。</strong> 這一題決定它最後會留在哪些地方。</p>
<p>送出去的機制（共享密鑰、API key、client credentials 換 token 的那一次）把秘密交給整條傳輸路徑上的每一跳處理，而那些跳點各自有自己的記錄行為——反向代理的存取日誌、內容傳遞網路的邊緣節點、應用層的請求追蹤、以及對方那一側的同一組系統。秘密沒有洩漏，只是被記錄了，而記錄的保存期限與可讀範圍由那些系統各自的設定決定。放進網址參數是這一格最嚴重的形態，因為多數伺服器的預設存取日誌格式會完整記錄請求行、含查詢字串；放進 header 好一些，但請求追蹤與錯誤回報工具可能會抓 header——這一項各家預設不同，要逐一翻設定。兩者都查得到：翻一次自己的日誌格式設定與追蹤工具的欄位設定就知道。</p>
<p>不送出去的機制（共享密鑰簽章、mTLS）只把秘密用來運算：網路上流的是簽章值或握手結果，秘密本身留在兩端的記憶體裡。代價是兩邊都要實作運算邏輯，而排錯比「比對一個字串」難得多——失敗時只會得到「對不起來」，看不出是哪一個欄位不一致。</p>
<p>下方表格用「<strong>送得起</strong>」指這一題的判定結果：那些跳點的紀錄保存期限與可讀範圍查得出來、而且查出來的答案可以接受。查不出來就當作送不起。</p>
<p>跳點全在自己掌控之內、日誌設定翻得到而且沒問題時，這一題五秒鐘就答完，決策整個落在第二題與基礎建設那一欄。純內網的服務之間互相呼叫多半是這種情形。</p>
<p><strong>問題二：撤銷一把憑證要影響幾個呼叫方。</strong> 這一題決定事件當天的處置範圍。</p>
<p>共享密鑰是一把服務全部，撤銷等於中斷整條整合，這條約束的實際份量見 <a href="../api-authentication-trust-boundaries/#%e8%ba%ab%e5%88%86%e7%b6%ad%e5%ba%a6%e5%88%86%e5%b1%a4%e6%a8%a1%e5%9e%8b">7.29 身分維度分層模型</a> —— 該節中段列出系統層的撤銷由哪些事件觸發、以及為什麼同一個「立刻撤銷」的需求在這一層要用天或週計算。API key 與 mTLS 憑證是一個呼叫方一把，撤銷影響單一對象——而 API key 的這個能力來自那張登記表，不是來自憑證本身。粒度細到什麼程度是設計選擇，不是機制給定的：共享密鑰也可以每個對象發一把，代價是要維護那張表，而那正是 API key 這個名字實際指的東西。</p>
<h2 id="兩題交叉之後的落點">兩題交叉之後的落點</h2>
<p>兩個問題各自有兩個答案，交叉出四格，每一格有對應的機制：</p>
<table>
  <thead>
      <tr>
          <th></th>
          <th><strong>只有一個呼叫方</strong></th>
          <th><strong>有多個呼叫方</strong></th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>秘密送得起</strong></td>
          <td>共享密鑰</td>
          <td>API key（要有登記表）</td>
      </tr>
      <tr>
          <td><strong>秘密送不起</strong></td>
          <td>共享密鑰簽章</td>
          <td>每個呼叫方一把簽章密鑰，或 mTLS</td>
      </tr>
  </tbody>
</table>
<p>右下那一格是實際上最常見的組合（同時接多個外部推送提供方、又不想讓密鑰進入日誌），而它的兩個選項成本差距在配套而非在密鑰數量：每方一把簽章密鑰要的是與 API key 同一張登記表，mTLS 要的是一整套憑證的簽發、續期與撤銷。前者是一張表、成本隨整合數變動，後者是一套基礎建設、多半是固定成本。所以整合數少時 mTLS 貴、共用同一個 CA 的整合夠多時成本結構會翻轉，攤提軸的算法見 <a href="../machine-credential-issuance/#%e9%85%8d%e7%99%bc%e6%98%af%e5%8f%af%e4%bb%a5%e8%a2%ab%e6%b6%88%e9%99%a4%e7%9a%84%e5%8b%95%e4%bd%9c">7.32 的信任錨交叉點</a>。</p>
<p>下表展開這些機制各自的性質。四格與下表的列<strong>不是一對一</strong>——共享密鑰簽章那一列同時承擔左下與右下兩格，差別在一把共用還是每方一把；右下那一格的另一個選項才是 mTLS。</p>
<table>
  <thead>
      <tr>
          <th>機制</th>
          <th>秘密要不要送出去</th>
          <th>撤銷一次影響幾個呼叫方</th>
          <th>要有的配套</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>共享密鑰</td>
          <td>要</td>
          <td>共用該密鑰的全部</td>
          <td>無</td>
      </tr>
      <tr>
          <td>API key</td>
          <td>要</td>
          <td>單一（靠登記表達成）</td>
          <td>憑證與呼叫方的登記表、發放介面</td>
      </tr>
      <tr>
          <td>共享密鑰簽章</td>
          <td>不要</td>
          <td>共用該密鑰的全部；每方一把時為單一</td>
          <td>兩端的簽章實作與素材規格</td>
      </tr>
      <tr>
          <td>mTLS</td>
          <td>不要</td>
          <td>單一</td>
          <td>CA、簽發與續期、撤銷清單或線上查詢</td>
      </tr>
  </tbody>
</table>
<p><strong>真正決定撤銷粒度的不是機制的名字，是有沒有一張把憑證對應到呼叫方的表。</strong> 共享密鑰與 API key 在密碼學上是同一件事——都是持有即通過、都做等值比對——差別只在那張表。有表，撤一把只影響一個；沒有，撤一把影響全部。名字本身靠不住：有服務把全帳號共用的靜態 token 叫 API key、也有服務按夥伴發放並登記卻叫它 shared secret。所以「我們改用 API key」這句話在團隊裡要講清楚實質內容是建那張表，否則會被聽成改個 header 名字。</p>
<p>這張表也可以不由自己維護：雲平台派發的身分（實例掛載的角色、容器編排的服務帳號）粒度同樣是單一，而對應關係在平台那一側，自己這邊看得到卻改不了。這條路徑不在上表裡，它的判讀走 <a href="../workload-identity-and-federated-trust/">7.10 Workload Identity 與聯邦信任邊界</a>，而它能不能用是 <a href="../machine-credential-issuance/#%e9%85%8d%e7%99%bc%e6%98%af%e5%8f%af%e4%bb%a5%e8%a2%ab%e6%b6%88%e9%99%a4%e7%9a%84%e5%8b%95%e4%bd%9c">7.32 的第一題</a>。</p>
<h3 id="mtls-的兩個補述">mTLS 的兩個補述</h3>
<p><strong>保護到哪裡結束。</strong> mTLS 與簽章的私鑰或密鑰都留在本地（TLS 用私鑰簽握手訊息、HMAC 用密鑰算驗證值），但兩者保護到的位置不同：mTLS 的客戶端身分在 TLS 終止的那一點就結束，前面有負載平衡器或內容傳遞網路終止 TLS 時，身分要由終止點轉成 header 往後傳，於是問題變成「後端憑什麼信任那個 header」。簽章值則一路走到應用層，中途每一跳都改不了它。有終止點的架構要把這一跳的信任邊界一起設計，見 <a href="../api-authentication-trust-boundaries/#%e6%b7%b7%e5%b1%a4%e4%b9%8b%e5%be%8c%e5%a4%b1%e5%8e%bb%e4%bb%80%e9%ba%bc">7.29 系統憑證下放客戶端</a>。</p>
<p><strong>撤了之後多久真的擋得住。</strong> 這與撤銷粒度是兩個問題：粒度是單一呼叫方，而生效時間由驗證側的檢查方式決定——撤銷清單有快取與更新間隔，線上查詢在查不到時多數實作預設放行。把延遲壓到有界的做法是縮短憑證有效期，而那要求續期是自動的，回到最右欄那一格。</p>
<h3 id="oauth-client-credentials-是另一個層次">OAuth client credentials 是另一個層次</h3>
<p>它是取得憑證的流程，不是第五種機制，而流程裡用哪一種方式向授權伺服器證明自己，才決定秘密送不送出去。用 client secret 時秘密要送、而且是每次換 token 都送一次：RFC 6749 §4.4.2 要求每次 token 請求都做 client 認證，§4.4.3 又說不該發 refresh token，所以 token 過期只能再送一次 secret 去換——暴露頻率因此等於實例數乘上一天的秒數再除以 token 有效期：token 一小時的單一實例一天走 24 趟，而水平擴展的每個實例各自換各自的，五十個實例就是一千兩百趟——規模放大的正是暴露量。</p>
<p>同一個流程換成用私鑰簽出來的斷言（RFC 7523）或 mTLS 做 client 認證（RFC 8705）時，秘密就不送出去了，這一格移到四格表的下半。標準本身留了這個空間：RFC 6749 §2.3 明寫授權伺服器可以接受任何滿足其安全需求的 client 認證方式。</p>
<p>它真正帶來的是另一項：長期憑證與每次呼叫實際使用的憑證被分開，於是可以對後者設短有效期與細範圍，範圍表達見 <a href="/blog/backend/knowledge-cards/authorization-scope/" data-link-title="Authorization Scope（授權範圍）" data-link-desc="把一次授權寫成可協商的單位時，用來判斷顆粒由誰決定、授予的範圍與實際用到的範圍差多少、以及事後收斂為什麼比事前貴">authorization scope</a>。已換出的 token 要等它過期還是撤得掉，取決於發行方有沒有提供撤銷端點與驗證方有沒有回查——無狀態驗證且不回查時才是「只能等它過期」。代價是換發端點成為每次呼叫的前置依賴。</p>
<h2 id="判讀流程">判讀流程</h2>
<ol>
<li>先問這條整合需不需要有一把要交出去的憑證。呼叫方能用執行環境本身證明身分時整套選型都不必進行，判讀走 <a href="../machine-credential-issuance/#%e9%85%8d%e7%99%bc%e6%98%af%e5%8f%af%e4%bb%a5%e8%a2%ab%e6%b6%88%e9%99%a4%e7%9a%84%e5%8b%95%e4%bd%9c">7.32 的第一題</a> 與 <a href="../workload-identity-and-federated-trust/">7.10</a>。這一題答是的機率隨平台能力上升，而它一旦成立，下面四步全部作廢。</li>
<li>再確認規格由誰寫。由對方寫時直接跳到最後的登記那一步。</li>
<li>再問這個秘密送不送得起：傳輸路徑上有幾跳會記錄請求、那些記錄的保存期限與可讀範圍是什麼。確認不了時預設它會被記錄，選不送出去的那一類。</li>
<li>接著數呼叫方。只有一個且短期內不會變時可以接受共用一把，並把「出現第二個呼叫方」寫成重新評估的觸發條件；兩個以上直接要一方一把的粒度。把這一題的答案與「秘密送不送得起」那一題交叉，落點見上方的四格表。</li>
<li>再確認選中的機制要的基礎建設現在就有。mTLS 要問憑證續期是不是自動的、換發流程要問 token 端點掛掉時呼叫方怎麼辦。沒有的話這一項的建置要與整合本身一起排，而不是排在它之後；排不進去時只剩兩條路——退回同一格裡成本較低的那個選項（右下那格退回每方一把簽章密鑰），或接受並記錄例外，例外的期限與重評估條件見下方風險邊界裡「對方只提供一種機制」那一條。</li>
<li>最後把選定的機制與上面各題的答案一起登記。自己核發憑證的接到 <a href="../machine-credential-issuance/">7.32 機器憑證的配發</a> 的核發與交付，登記落在那一章定義的那份清單上。落點是 <a href="../machine-credential-issuance/#%e9%85%8d%e7%99%bc%e5%85%8d%e4%b8%8d%e6%8e%89%e6%99%82%e8%a6%81%e5%ae%9a%e4%bb%80%e9%ba%bc">7.32 定義的那份清單</a>，本章在那份清單上加一個欄位（機制）與一種列型態：沒有選擇權、也沒有自己發出憑證的整合同樣佔一列，機制欄寫「由對方決定」，另記差額風險與重評估觸發器。</li>
</ol>
<h2 id="問題節點案例觸發式">問題節點（案例觸發式）</h2>
<table>
  <thead>
      <tr>
          <th>問題節點</th>
          <th>判讀訊號</th>
          <th>風險後果</th>
          <th>前置控制面</th>
          <th>交接路由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>秘密隨請求落進紀錄</td>
          <td>秘密出現在網址參數，或請求追蹤抓完整 header</td>
          <td>秘密的可讀範圍等於各跳點紀錄的保存範圍</td>
          <td><a href="/blog/backend/knowledge-cards/secret-management/" data-link-title="Secret Management" data-link-desc="說明 token、key、password 與憑證如何保存、輪替與撤銷">secret-management</a>、<a href="/blog/backend/knowledge-cards/audit-log/" data-link-title="Audit Log" data-link-desc="說明高風險操作如何留下可追溯、可稽核的紀錄">audit-log</a></td>
          <td><code>04 + 06</code></td>
      </tr>
      <tr>
          <td>撤銷粒度與呼叫方不匹配</td>
          <td>一把憑證服務兩個以上的呼叫方</td>
          <td>撤銷單一對象要中斷全部</td>
          <td><a href="/blog/backend/knowledge-cards/token-revocation/" data-link-title="Token Revocation" data-link-desc="說明事件中如何撤銷 token，縮短可利用窗口">token-revocation</a>、<a href="/blog/backend/knowledge-cards/credential/" data-link-title="Credential" data-link-desc="整理身分驗證與系統存取用秘密資料">credential</a></td>
          <td><code>06 + 08</code></td>
      </tr>
      <tr>
          <td>基礎建設沒有跟上</td>
          <td>選了 mTLS 而簽發與續期是手動</td>
          <td>同批簽發的憑證同時到期，整合同時中斷</td>
          <td><a href="/blog/backend/knowledge-cards/certificate-rotation-renewal/" data-link-title="Certificate Rotation and Renewal" data-link-desc="說明網站憑證如何安全續期與輪替以避免停機">certificate-rotation-renewal</a>、<a href="/blog/backend/knowledge-cards/acme-automation/" data-link-title="ACME Automation" data-link-desc="說明網站憑證如何透過 ACME 自動簽發與續期">acme-automation</a></td>
          <td><code>05 + 06</code></td>
      </tr>
      <tr>
          <td>換發那一跳成為單點</td>
          <td>token 端點故障時呼叫方沒有降級路徑</td>
          <td>長期秘密仍然有效，而所有呼叫方同時失去存取</td>
          <td><a href="/blog/backend/knowledge-cards/fallback/" data-link-title="Fallback" data-link-desc="說明主要路徑失敗時使用替代結果或替代流程的設計責任">fallback</a>、<a href="/blog/backend/knowledge-cards/circuit-breaker/" data-link-title="Circuit Breaker" data-link-desc="說明下游持續失敗時如何暫停呼叫並保護系統">circuit-breaker</a></td>
          <td><code>06</code></td>
      </tr>
  </tbody>
</table>
<h2 id="問題節點出現在什麼樣的系統">問題節點出現在什麼樣的系統</h2>
<p>上表的訊號要等機制上線才量得到。設計階段對照的是下面這幾種形態。</p>
<p><strong>秘密隨請求落進紀錄</strong>出現在把它當成一般參數處理的整合。網址參數這一種有兩種來源、處置不同：自己這邊「先讓它通」的除錯殘留改得掉，而對方的 API 只收查詢字串、或對方的 webhook 主控台設不了 header 這一種改不掉，只剩在自己這一側遮罩日誌並記成已知風險；header 那一種則是被工具帶進來的——請求追蹤與錯誤回報服務的預設多半抓完整 header，而那個預設在導入時沒有人逐項看過。識別動作是拿一段時間的存取紀錄搜尋自己的憑證前綴。</p>
<p><strong>撤銷粒度與呼叫方不匹配</strong>出現在憑證比呼叫方先存在的系統。第一條整合開了一把密鑰，第二條來的時候那把已經能用，於是沿用——這與 <a href="../secrets-and-machine-credential-governance/#%e5%95%8f%e9%a1%8c%e7%af%80%e9%bb%9e%e5%87%ba%e7%8f%be%e5%9c%a8%e4%bb%80%e9%ba%bc%e6%a8%a3%e7%9a%84%e7%b3%bb%e7%b5%b1">7.6 token 分域不足</a> 是同一個機制在機制選型層的形態。識別特徵是問得出「這把憑證現在有誰在用」的人只有一位。</p>
<p><strong>基礎建設沒有跟上</strong>出現在安全評審把機制選對、而維運能力沒有一起評估的組織。選 mTLS 的決定在設計階段做，憑證續期的痛在一年後才發生，兩者中間隔著一次交接。它還有一半不在自己這一側：續期要對方同時換，所以就算自己這邊自動化做好了，中斷時間仍由對方的排期決定。</p>
<p><strong>換發那一跳成為單點</strong>出現在剛從長期憑證換到動態換發的系統。換過去的動機是壓縮暴露窗口，而那個動機不會帶出「這個端點掛掉會怎樣」這個問題。最小做法有三項、都在呼叫方那一側：把換到的憑證快取到接近過期而不是每次呼叫都換、在過期前留一段提前續期的窗口讓失敗有第二次機會、以及換發失敗時明確決定要擋下請求還是沿用尚未過期的那一張。三項合起來把換發端點從「每次呼叫的前置」降成「每個週期的前置」，降級策略的形態見 <a href="/blog/backend/knowledge-cards/fallback/" data-link-title="Fallback" data-link-desc="說明主要路徑失敗時使用替代結果或替代流程的設計責任">fallback</a>，那一跳本身的可用性目標走 <a href="/blog/backend/06-reliability/" data-link-title="模組六：可靠性驗證流程" data-link-desc="用 SRE 領域詞彙建問題節點、以服務級案例庫累積驗證脈絡，先建概念與案例庫再進實作交接">06 可靠性</a>。</p>
<p>基礎建設沒有跟上是這張表裡唯一在選型當下不留任何痕跡的一格。合規要求對外整合走 mTLS，團隊建了一個內部 CA、為當時的兩個對接方各簽了一張一年期憑證，上線順利。一年後兩張憑證在同一週到期，兩條整合同時斷，而那一週沒有任何人在等這件事發生。</p>
<p>沒有被提前發現，是因為憑證到期不產生漸進訊號：到期前一秒的連線與平常完全相同，監控盯的連線成功率在到期當下垂直落下、沒有前兆。「還剩多久到期」要主動去查才有，而查它的動作在自動續期尚未建立的環境裡沒有承載者——它不屬於任何一個既有的排程。</p>
<p>止血要重新簽發並在兩端同時換，而對方那一側要人工配合，於是中斷時間由對方的排期決定。真正的修法在選型當下：把續期自動化當成選 mTLS 的前置條件而非後續工作，路徑見 <a href="/blog/backend/knowledge-cards/acme-automation/" data-link-title="ACME Automation" data-link-desc="說明網站憑證如何透過 ACME 自動簽發與續期">acme-automation</a> 與 <a href="../transport-trust-and-certificate-lifecycle/">7.5 傳輸信任與憑證生命週期</a>。</p>
<h2 id="常見風險邊界">常見風險邊界</h2>
<ul>
<li>秘密出現在網址參數時，代表它的可讀範圍已經無法界定——各跳點的紀錄保存期限不由自己決定，處置要連同輪替一起做，輪替本身的節奏走 <a href="../secrets-and-machine-credential-governance/">7.6 秘密管理與機器憑證治理</a>。</li>
<li>同一把憑證服務兩個以上的呼叫方，而其中任一個是外部組織時，撤銷粒度的缺口會在事件當天變成「要中斷幾條整合」這個沒有好答案的問題。</li>
<li>選定的機制要求的基礎建設由人工承擔時，這條整合的可用性上限由那個人的排程決定，這超出工程修復能承擔的範圍。</li>
<li>換發端點沒有獨立的可用性目標時，代表所有依賴它的整合共用一個沒有人負責的單點。</li>
<li>對方只提供一種機制而那一種不符合自己的合規要求時，處置要往合約層或架構層走：要求對方支援、在中間加一層自己控制的代理、或接受並記錄例外，例外的期限與重評估條件見 <a href="../security-governance-exception-and-tripwire/">7.14 資安治理例外與 Tripwire</a>。</li>
</ul>
<h2 id="案例觸發參考">案例觸發參考</h2>
<ul>
<li>硬編碼憑證被檢索重用： <a href="../red-team/cases/edge-exposure/usaherds-cve-2021-44207-hardcoded-credential/">USAHERDS 2021</a></li>
<li>憑證集中與輪替排序壓力： <a href="../red-team/cases/supply-chain/circleci-2023-secrets-rotation/">CircleCI 2023</a></li>
<li>第三方 token 成為內部入口： <a href="../red-team/cases/supply-chain/github-oauth-2022-token-supply-chain/">GitHub OAuth 2022</a></li>
<li>輪替的作用域與證據欄位示範： <a href="../credential-rotation-scoped-evidence/">7.27 Credential Rotation with Scoped Evidence</a></li>
</ul>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>上游（要不要有獨立的系統層身分）：<a href="../api-authentication-trust-boundaries/">7.29 API 認證的信任邊界分層</a></li>
<li>選定之後憑證怎麼核發、交付與登記：<a href="../machine-credential-issuance/">7.32 機器憑證的配發</a></li>
<li>選了共享密鑰簽章之後的素材對齊與重放收斂：<a href="../signature-integration-verification/">7.35 簽章對接的驗證收斂</a></li>
<li>這個機制屬於哪一類原語、金鑰放在哪一格：<a href="../cryptographic-primitive-selection/">7.28 密碼學原語選型</a></li>
<li>mTLS 的憑證生命週期判讀：<a href="../transport-trust-and-certificate-lifecycle/">7.5 傳輸信任與憑證生命週期</a></li>
<li>持有者是人而非系統時判讀軸換成載體（passkey、安全金鑰、智慧卡）：<a href="../user-held-credential-carrier/">7.39 使用者持有型憑證</a></li>
<li>人類那一側的憑證怎麼帶進每個請求：<a href="../credential-transport-in-request/">7.36 憑證在請求中怎麼帶</a></li>
<li>上線後的輪替、回收與事件收斂：<a href="../secrets-and-machine-credential-governance/">7.6 秘密管理與機器憑證治理</a></li>
<li>各機制的實作細節（雙密過渡、mTLS 部署、簽章實作）：<a href="/blog/work-log/api_auth_trust_boundaries/" data-link-title="API 認證的三層信任邊界：使用者、系統、跨系統 Provisioning" data-link-desc="API 認證的信任邊界分層（Bearer Token / Shared Secret / Provisioning）：各層的洩漏後果與撤銷方式，以及混用造成的設計失效模式。">API 認證的三層信任邊界</a> 的「Layer 2：系統層」一節與它列出的各篇</li>
<li>例外的期限與重評估：<a href="../security-governance-exception-and-tripwire/">7.14 資安治理例外與 Tripwire</a></li>
</ul>
]]></content:encoded></item><item><title>7.35 簽章對接的驗證收斂：驗簽通過之後還缺哪一塊</title><link>https://tarrragon.github.io/blog/backend/07-security-data-protection/signature-integration-verification/</link><pubDate>Wed, 29 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/07-security-data-protection/signature-integration-verification/</guid><description>&lt;p>驗簽通過之後還有三件事要做，而它們在功能測試裡都不會出現。&lt;/p>
&lt;p>這一章要交出去的是三樣東西：一份雙方確認過的驗證素材規格、一個換算得出上下界的窗口長度、以及一組分得開的拒絕原因。第一樣決定對接要花多久，第二樣決定驗簽通過之後還擋不擋得住重送，第三樣決定事件當天查得出方向。另有一項不必等對方就能修完：比對驗證值那一行用對函式。&lt;/p>
&lt;h2 id="本章涵蓋與不涵蓋">本章涵蓋與不涵蓋&lt;/h2>
&lt;p>本章的三個收斂條件涵蓋範圍不同：驗證素材的對齊是共享密鑰簽章專屬，而重放窗口對任何接收外部推送的端點都成立（與用哪一種機制無關），比對方式對任何做等值比對的秘密都成立（API key 的比對有一模一樣的時間洩漏）。機制本身還沒選的讀者從 &lt;a href="../machine-credential-mechanism-selection/">7.34 機器憑證的機制選型&lt;/a> 進來；這個機制屬於哪一類原語、密鑰放在哪一格見 &lt;a href="../cryptographic-primitive-selection/">7.28 密碼學原語選型&lt;/a>；密鑰的保存與輪替屬治理層，見 &lt;a href="../secrets-and-machine-credential-governance/">7.6 秘密管理與機器憑證治理&lt;/a>。本章聚焦選定之後的收斂條件，機制本身的原理在 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/message-authentication/" data-link-title="Message Authentication" data-link-desc="兩個系統用共享密鑰互相呼叫時，用來判斷驗證值保護到什麼範圍、撤銷粒度落在哪一層">Message Authentication&lt;/a>。下方反覆出現的&lt;strong>驗證素材&lt;/strong>指的是進入計算的那串內容——哪些欄位、以什麼順序、用什麼編碼串接起來。&lt;/p>
&lt;h2 id="本章-threat-scope">本章 threat scope&lt;/h2>
&lt;p>&lt;strong>In-scope&lt;/strong>：驗證素材的定義兩端不一致 / 重放窗口未收斂 / 驗證值的比對方式抵銷機制強度。&lt;/p>
&lt;p>&lt;strong>Out-of-scope&lt;/strong>（路由到他章）：&lt;/p>
&lt;ul>
&lt;li>該不該用共享密鑰簽章、與其他機制的取捨 → &lt;a href="../machine-credential-mechanism-selection/">7.34&lt;/a>&lt;/li>
&lt;li>密鑰放在哪一格、被誰拿得到 → &lt;a href="../cryptographic-primitive-selection/">7.28&lt;/a>&lt;/li>
&lt;li>密鑰的保存與輪替節奏 → &lt;a href="../secrets-and-machine-credential-governance/">7.6&lt;/a>&lt;/li>
&lt;li>素材規格變更時的相容判斷（本章不談版本策略，只到「規格要雙方共同確認」為止）→ &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/backward-compatibility-discipline/" data-link-title="11.6 向後相容的變更紀律" data-link-desc="哪些變更算 breaking、相容性檢查放人工還是 CI、檢查粒度怎麼選 — 讓介面變更可審可擋的日常紀律">11.6 向後相容的變更紀律&lt;/a> 的抽象層紀律&lt;/li>
&lt;/ul>
&lt;p>out-of-scope 的議題直接跳到對應章節。&lt;/p>
&lt;h2 id="從本章到實作">從本章到實作&lt;/h2>
&lt;p>本章是 routing layer，沿兩條 chain 進入 implementation：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Mechanism&lt;/strong>：問題節點表「前置控制面」欄的連結進知識卡，看該控制的機制、邊界與適用條件。&lt;/li>
&lt;li>&lt;strong>Delivery&lt;/strong>：「交接路由」欄位指向 &lt;a href="https://tarrragon.github.io/blog/backend/04-observability/" data-link-title="模組四：可觀測性平台" data-link-desc="整理 log、metric、trace、dashboard 與 alert 的後端操作實務">04 可觀測性&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/05-deployment-platform/" data-link-title="模組五：部署平台與網路入口" data-link-desc="整理 Kubernetes、systemd、load balancer、container 與服務生命週期合約">05 部署平台&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/06-reliability/" data-link-title="模組六：可靠性驗證流程" data-link-desc="用 SRE 領域詞彙建問題節點、以服務級案例庫累積驗證脈絡，先建概念與案例庫再進實作交接">06 可靠性&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/08-incident-response/" data-link-title="模組八：事故處理與復盤" data-link-desc="用 IR 領域詞彙建問題節點、以服務級案例庫累積事故脈絡，先建概念與案例庫再進實作交接">08 事故處理&lt;/a>。重放窗口那一列走 04，因為拒絕原因分不分得開是監控設計的問題。&lt;/li>
&lt;/ul>
&lt;p>兩條 chain 完成判準與模組級 chain 規格見 &lt;a href="../#%e5%be%9e%e7%ab%a0%e7%af%80%e5%88%b0%e5%af%a6%e4%bd%9c%e7%9a%84-chain">從章節到實作的 chain&lt;/a>。&lt;/p>
&lt;h2 id="問題節點案例觸發式">問題節點（案例觸發式）&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>問題節點&lt;/th>
 &lt;th>判讀訊號&lt;/th>
 &lt;th>風險後果&lt;/th>
 &lt;th>前置控制面&lt;/th>
 &lt;th>交接路由&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>驗證素材定義不一致&lt;/td>
 &lt;td>兩端各自解讀欄位順序、單位與空值處理&lt;/td>
 &lt;td>對接失敗且錯誤訊息無法定位&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/message-authentication/" data-link-title="Message Authentication" data-link-desc="兩個系統用共享密鑰互相呼叫時，用來判斷驗證值保護到什麼範圍、撤銷粒度落在哪一層">message-authentication&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/api-contract/" data-link-title="API Contract" data-link-desc="說明 request / response 邊界如何維持相容與可驗證">api-contract&lt;/a>&lt;/td>
 &lt;td>&lt;code>05&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>重放窗口未收斂&lt;/td>
 &lt;td>驗證值涵蓋時間戳但接收端未檢查新鮮度&lt;/td>
 &lt;td>攔截到的請求可無限重放&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/replay-attack/" data-link-title="Replay Attack" data-link-desc="攔截到的合法請求被原封不動再送一次時，用來判斷哪一層該負責擋、以及擋不住會發生什麼">replay-attack&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/idempotency-key/" data-link-title="Idempotency Key（冪等鍵）" data-link-desc="同一操作重送時該由誰生成識別碼、存多久、衝突怎麼回——冪等性質的對外契約落地機制">idempotency-key&lt;/a>&lt;/td>
 &lt;td>&lt;code>04 + 06 + 08&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>比對方式抵銷強度&lt;/td>
 &lt;td>驗證值用一般字串相等運算比對&lt;/td>
 &lt;td>回應時間洩漏吻合前綴的長度&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/timing-attack/" data-link-title="Timing Attack" data-link-desc="比對密鑰、token 或簽章的程式碼要判斷是否會由執行時間洩漏資訊時的依據">timing-attack&lt;/a>&lt;/td>
 &lt;td>&lt;code>05&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="判讀流程">判讀流程&lt;/h2>
&lt;ol>
&lt;li>先判這個端點需不需要時間軸的保護：重送一次會不會產生第二次副作用。不會的純查詢端點到這裡就結束，剩下兩項仍然要做。&lt;/li>
&lt;li>需要的話定窗口長度。第一題是對方的時間戳在事件產生時簽還是每次送出時簽，它決定下界怎麼算；換算與上下界交叉時的處置見下方「重放窗口的收斂條件」。&lt;/li>
&lt;li>接著確認素材規格是雙方共同確認過的一份，而不是各自一份文件。缺的話在第一次對接時補。自助式的供應商沒有那個對話（註冊完就上線、沒有窗口可談），這時退到單方規格：把實際收到的請求原樣存下幾筆當成規格的依據、據此寫回歸測試，並在文件裡標明依據是實測而非對方的文件。這樣做的差別在對方改了東西時測試會紅，而不是等到驗簽開始失敗才發現。&lt;/li>
&lt;li>再查程式碼裡比對驗證值那一行用的是不是等時比較函式。&lt;/li>
&lt;li>最後把窗口長度、素材規格、拒絕原因的分類與第 4 步的檢查結果寫下來。第 1 步判定為不需要時同樣要寫，記的是判定依據（這個端點重送不產生第二次副作用）——否則判定過而判定不需要，與根本沒判過，在產物上看起來一樣。自己是推送方時這幾項屬於對外契約的內容；自己是接收方時對外契約在對方手上，這幾項落在自己的整合紀錄與監控設定裡。兩種情形共同的最低要求是拒絕原因分得開，事件當下才查得出方向。&lt;/li>
&lt;/ol>
&lt;h2 id="驗證素材的對齊成本">驗證素材的對齊成本&lt;/h2>
&lt;p>素材定義不一致是簽章對接裡反覆發生的耗時來源，特徵是&lt;strong>失敗訊號不具指向性&lt;/strong>：驗證值對不起來時只會得到一個布林值，看不出是欄位少了一個、順序反了、還是時間戳的單位是秒而對方送的是毫秒。&lt;/p>
&lt;p>這個特徵決定了它的成本結構：耗時不隨團隊能力下降，而是隨介面調整次數重複發生。每一次新增欄位、改變編碼、調整空值處理，兩端都要重新對齊一次，而每一次對齊都要付同樣的排錯成本。因此把素材規格寫進雙方共同的書面契約，這筆投資在整合的生命週期裡會被攤提多次——而寫進去的時機在第一次對接時成本最低，那時兩邊的人都還在同一個對話裡。&lt;/p>
&lt;p>規格要涵蓋的項目、可在本機單方完成的診斷手法（先讓兩端各自對同一份輸入算一次，比對中間值而非最終值）與對接的檢查順序見 &lt;a href="https://tarrragon.github.io/blog/work-log/hmac_signature_field_alignment/" data-link-title="HMAC 簽章對接：對不上的是輸入定義、用確定性反推它落在哪一端" data-link-desc="兩端 HMAC 算不出同一個值時要逐項核對的輸入定義，以及用簽章反推輸入、判斷問題落在哪一端的除錯手法。">HMAC 簽章對接&lt;/a>。&lt;/p>
&lt;p>&lt;strong>驗證素材定義不一致&lt;/strong>要有兩個團隊各自實作才會發生，因此它的密度與對接對象數量成正比。跨組織整合、集團內跨產品線、以及自己的服務對接第三方的 webhook 都落在這裡。識別特徵是雙方各有一份描述簽章怎麼算的文件，而沒有一份是雙方共同確認過的。&lt;/p>
&lt;h2 id="重放窗口的收斂條件">重放窗口的收斂條件&lt;/h2>
&lt;p>哪些端點需要這一步，判準是重送一次會不會產生第二次副作用：轉帳、下單、發送通知、狀態轉移都會，純查詢端點不會。接收外部 webhook 的端點風險最高——請求來自自己控制範圍之外，攻擊者可能就在傳輸路徑上，而 webhook 的重試機制本身就會產生大量重複請求，讓惡意重放藏在正常重試裡。&lt;/p>
&lt;p>時間軸條件（驗證值涵蓋時間戳、接收端檢查新鮮度）擋住窗口外的重送，窗口內的重複由識別值去重承接，兩者的分工見 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/replay-attack/" data-link-title="Replay Attack" data-link-desc="攔截到的合法請求被原封不動再送一次時，用來判斷哪一層該負責擋、以及擋不住會發生什麼">Replay Attack&lt;/a>。這個節點的特殊之處在於缺的那一步從來沒有被排進工作項：驗簽做完了、測試綠了、上線了，而「還要比對時間戳與識別值」這件事沒有出現在任何一張清單上，所以它不是做失敗、是沒有人想到要做。&lt;/p>
&lt;p>它的失敗長這樣：webhook 接收端照對方文件實作了簽章驗證，驗簽通過就代表這個請求出自對方，當下沒有人覺得還缺什麼——時間戳與識別值都在 payload 裡，兩者都沒有人比對。某次對方那端重試堆積之後，同一筆扣款被處理了好幾次。查起來每一筆都通過驗簽、每一筆的內容都合法，監控上是一串正常請求；而 webhook 本來就會重試，重複請求與惡意重放在日誌裡沒有任何欄位分得開。&lt;/p>
&lt;p>補救的順序由重複落在哪一側決定，而這個案例落在窗口內側：重試堆積發生在幾秒到幾分鐘之內，新鮮度檢查放行它是正確行為，擋得住它的是識別值去重。所以先建去重儲存，再補時間戳的新鮮度檢查把窗口外的重送一併擋掉——兩道控制的分工見上一段。已經發生的重複副作用要逐筆對帳回滾，那部分的工作量由這個缺口存在了多久決定。&lt;/p>
&lt;p>窗口長度是對接階段就要定的參數，兩端都能換算成具體的量。下界由對方的時間戳在什麼時候簽決定：送出時簽的話每次重試各自帶新的時間戳，下界就是量測到的 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/clock-skew/" data-link-title="Clock Skew" data-link-desc="跨機器比較時間才成立的機制（時效窗口、憑證有效期、事件排序）在決定容忍值時的判斷依據">時鐘偏移&lt;/a> 上界加餘裕，落在秒到分鐘級；產生時簽的話整串重試共用同一個時間戳，下界要改取時鐘偏移上界與對方最大重試退避間隔的較大者，而後者通常大上幾個數量級——退避超過窗口的那些合法重試會被判成過期，日誌上與攻擊分不開。這一題要向對方確認，退避表查不到時當作產生時簽並取對方公告的重試總時長，&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/exponential-backoff/" data-link-title="Exponential Backoff" data-link-desc="說明重試間隔如何逐步拉長以降低下游壓力">指數退避&lt;/a> 是它最常見的形狀。&lt;/p>
&lt;p>上界由去重的儲存量反推：窗口內的已處理識別值都要留著，請求速率乘上窗口長度就是要保留的筆數。這個數字撐不住時要動的是儲存形態而非窗口——存識別值的雜湊而非原值、讓儲存自己按存活時間淘汰——因為把窗口縮到下界以下會開始拒絕合法的重試，那與攻擊在日誌上分不開。兩端交叉到怎麼調都撐不住時，剩下的路是接受窗口外的重複由業務層的冪等承接，收斂點見 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/idempotency-key/" data-link-title="Idempotency Key（冪等鍵）" data-link-desc="同一操作重送時該由誰生成識別碼、存多久、衝突怎麼回——冪等性質的對外契約落地機制">Idempotency Key&lt;/a>。涉及金流或不可逆動作的端點直接取下界，不必在區間裡挑。&lt;/p>
&lt;p>&lt;strong>重放窗口未收斂&lt;/strong>出現在接收外部推送的端點，尤其是那些照著對方文件實作驗簽就上線的整合。這一格的成因與其餘節點不同：它不是做錯，是做完之後少做一步，因此它的出現率與驗簽實作的正確性無關——寫得越乾淨的實作越容易停在這裡，因為驗簽那一段看起來已經完成了。識別特徵是 payload 裡帶著時間戳與識別值，而接收端的程式碼裡找不到比對它們的地方。&lt;/p>
&lt;h2 id="比對方式抵銷機制強度">比對方式抵銷機制強度&lt;/h2>
&lt;p>重算出的值要用等時比較來比對。一般的字串相等運算在第一個不吻合的位元組就回傳，回應時間因此洩漏吻合前綴的長度，攻擊者可以逐位元組推出正確的驗證值，收斂點見 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/timing-attack/" data-link-title="Timing Attack" data-link-desc="比對密鑰、token 或簽章的程式碼要判斷是否會由執行時間洩漏資訊時的依據">Timing Attack&lt;/a>。&lt;/p>
&lt;p>&lt;strong>比對方式抵銷強度&lt;/strong>出現在自己動手實作驗簽的服務。用對方提供的 SDK 多半不會踩到，因為那一行藏在函式庫裡（多半而非一定——SDK 沒覆蓋到的框架仍要自己寫）；照文件自己寫的會踩到，因為文件多半只說明怎麼算出那個值、不說怎麼比對它。識別動作是查程式碼裡比對驗證值那一行。&lt;/p>
&lt;p>各語言都有現成的等時比較函式（&lt;code>hash_equals&lt;/code>、&lt;code>compare_digest&lt;/code>、&lt;code>ConstantTimeCompare&lt;/code> 這一類），判別方式是查程式碼裡比對驗證值那一行用的是不是那個函式。函式取用不到的環境還有第二條路：把收到的值與自己算出的值各再做一次 HMAC（用一把當場產生的隨機密鑰）之後比對，攻擊者無法預測比對的是什麼，時間差因此不再洩漏前綴長度。它與其餘節點的差別在它完全在自己這一側，不必與對方協調。&lt;/p>
&lt;h2 id="常見風險邊界">常見風險邊界&lt;/h2>
&lt;ul>
&lt;li>驗證素材沒有雙方共同的書面規格時，代表對接成本會在每次介面調整時重新發生，而那筆成本每次都由兩邊同時付。&lt;/li>
&lt;li>端點會產生第二次副作用而時間戳與識別值都沒有被比對時，保護只剩「這個請求出自對方」，攔截到的請求可以無限重放。&lt;/li>
&lt;li>窗口長度取得比量測到的時鐘偏移還短時，正常請求會在漂移時被拒，而那個拒絕在日誌裡與攻擊的表徵相同。&lt;/li>
&lt;li>監控把「驗證失敗」併成一個計數時，事件當下無法從監控判斷該往哪個方向查。三者指向不同：驗證值不符指向規格或密鑰不一致、時間戳過期指向時鐘偏移、識別值重複則是三者裡唯一需要區分惡意與正常重試的一種——它的絕大多數來源是對方的重試，所以不能只看計數，要查得到來源位址與時間分布。&lt;/li>
&lt;li>對方的文件與實際送出的內容不一致而對方不願修文件時，處置要往契約層走：把實測結果寫成雙方確認過的附件，並把自己這一側的實作依據記成「依實測而非依文件」。對方連附件都不給時這條整合帶著一個沒有書面依據的假設上線，期限與重評估條件走 &lt;a href="../security-governance-exception-and-tripwire/">7.14 資安治理例外與 Tripwire&lt;/a>。&lt;/li>
&lt;/ul>
&lt;h2 id="案例觸發參考">案例觸發參考&lt;/h2>
&lt;ul>
&lt;li>簽章方案在對外契約上的實際形態： &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/cases/webhook-stripe-delivery-contract/" data-link-title="11.C60 Stripe webhooks：at-least-once 加簽章、明文要求 consumer 冪等與不依賴順序" data-link-desc="webhook 對外承諾的教科書樣本：三天重試、重複投遞、no-ordering、簽章驗證的責任在同一頁明文轉移給 consumer">Stripe webhook 投遞契約&lt;/a>&lt;/li>
&lt;li>硬編碼憑證與固定認證路徑： &lt;a href="../red-team/cases/edge-exposure/usaherds-cve-2021-44207-hardcoded-credential/">USAHERDS 2021&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>上游（該不該用共享密鑰簽章）：&lt;a href="../machine-credential-mechanism-selection/">7.34 機器憑證的機制選型&lt;/a>&lt;/li>
&lt;li>機制原理與能力上限：&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/message-authentication/" data-link-title="Message Authentication" data-link-desc="兩個系統用共享密鑰互相呼叫時，用來判斷驗證值保護到什麼範圍、撤銷粒度落在哪一層">Message Authentication&lt;/a>&lt;/li>
&lt;li>素材的逐項清單與診斷手法：&lt;a href="https://tarrragon.github.io/blog/work-log/hmac_signature_field_alignment/" data-link-title="HMAC 簽章對接：對不上的是輸入定義、用確定性反推它落在哪一端" data-link-desc="兩端 HMAC 算不出同一個值時要逐項核對的輸入定義，以及用簽章反推輸入、判斷問題落在哪一端的除錯手法。">HMAC 簽章對接&lt;/a>&lt;/li>
&lt;li>密鑰放在哪一格、被誰拿得到：&lt;a href="../cryptographic-primitive-selection/">7.28 密碼學原語選型&lt;/a>&lt;/li>
&lt;li>密鑰怎麼交到對方手上：&lt;a href="../machine-credential-issuance/">7.32 機器憑證的配發&lt;/a>&lt;/li>
&lt;li>密鑰的輪替與回收節奏：&lt;a href="../secrets-and-machine-credential-governance/">7.6 秘密管理與機器憑證治理&lt;/a>&lt;/li>
&lt;li>去重儲存與識別值的設計：&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/idempotency-key/" data-link-title="Idempotency Key（冪等鍵）" data-link-desc="同一操作重送時該由誰生成識別碼、存多久、衝突怎麼回——冪等性質的對外契約落地機制">Idempotency Key&lt;/a>&lt;/li>
&lt;li>素材規格新增欄位時兩端怎麼同步：本站尚無專章；通用的相容紀律見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/backward-compatibility-discipline/" data-link-title="11.6 向後相容的變更紀律" data-link-desc="哪些變更算 breaking、相容性檢查放人工還是 CI、檢查粒度怎麼選 — 讓介面變更可審可擋的日常紀律">11.6 向後相容的變更紀律&lt;/a>，在那之前的最小做法是把新欄位設成可選、兩端各自先支援「有或沒有都算對」一段時間，再切成必填&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>驗簽通過之後還有三件事要做，而它們在功能測試裡都不會出現。</p>
<p>這一章要交出去的是三樣東西：一份雙方確認過的驗證素材規格、一個換算得出上下界的窗口長度、以及一組分得開的拒絕原因。第一樣決定對接要花多久，第二樣決定驗簽通過之後還擋不擋得住重送，第三樣決定事件當天查得出方向。另有一項不必等對方就能修完：比對驗證值那一行用對函式。</p>
<h2 id="本章涵蓋與不涵蓋">本章涵蓋與不涵蓋</h2>
<p>本章的三個收斂條件涵蓋範圍不同：驗證素材的對齊是共享密鑰簽章專屬，而重放窗口對任何接收外部推送的端點都成立（與用哪一種機制無關），比對方式對任何做等值比對的秘密都成立（API key 的比對有一模一樣的時間洩漏）。機制本身還沒選的讀者從 <a href="../machine-credential-mechanism-selection/">7.34 機器憑證的機制選型</a> 進來；這個機制屬於哪一類原語、密鑰放在哪一格見 <a href="../cryptographic-primitive-selection/">7.28 密碼學原語選型</a>；密鑰的保存與輪替屬治理層，見 <a href="../secrets-and-machine-credential-governance/">7.6 秘密管理與機器憑證治理</a>。本章聚焦選定之後的收斂條件，機制本身的原理在 <a href="/blog/backend/knowledge-cards/message-authentication/" data-link-title="Message Authentication" data-link-desc="兩個系統用共享密鑰互相呼叫時，用來判斷驗證值保護到什麼範圍、撤銷粒度落在哪一層">Message Authentication</a>。下方反覆出現的<strong>驗證素材</strong>指的是進入計算的那串內容——哪些欄位、以什麼順序、用什麼編碼串接起來。</p>
<h2 id="本章-threat-scope">本章 threat scope</h2>
<p><strong>In-scope</strong>：驗證素材的定義兩端不一致 / 重放窗口未收斂 / 驗證值的比對方式抵銷機制強度。</p>
<p><strong>Out-of-scope</strong>（路由到他章）：</p>
<ul>
<li>該不該用共享密鑰簽章、與其他機制的取捨 → <a href="../machine-credential-mechanism-selection/">7.34</a></li>
<li>密鑰放在哪一格、被誰拿得到 → <a href="../cryptographic-primitive-selection/">7.28</a></li>
<li>密鑰的保存與輪替節奏 → <a href="../secrets-and-machine-credential-governance/">7.6</a></li>
<li>素材規格變更時的相容判斷（本章不談版本策略，只到「規格要雙方共同確認」為止）→ <a href="/blog/backend/11-api-design/backward-compatibility-discipline/" data-link-title="11.6 向後相容的變更紀律" data-link-desc="哪些變更算 breaking、相容性檢查放人工還是 CI、檢查粒度怎麼選 — 讓介面變更可審可擋的日常紀律">11.6 向後相容的變更紀律</a> 的抽象層紀律</li>
</ul>
<p>out-of-scope 的議題直接跳到對應章節。</p>
<h2 id="從本章到實作">從本章到實作</h2>
<p>本章是 routing layer，沿兩條 chain 進入 implementation：</p>
<ul>
<li><strong>Mechanism</strong>：問題節點表「前置控制面」欄的連結進知識卡，看該控制的機制、邊界與適用條件。</li>
<li><strong>Delivery</strong>：「交接路由」欄位指向 <a href="/blog/backend/04-observability/" data-link-title="模組四：可觀測性平台" data-link-desc="整理 log、metric、trace、dashboard 與 alert 的後端操作實務">04 可觀測性</a>、<a href="/blog/backend/05-deployment-platform/" data-link-title="模組五：部署平台與網路入口" data-link-desc="整理 Kubernetes、systemd、load balancer、container 與服務生命週期合約">05 部署平台</a>、<a href="/blog/backend/06-reliability/" data-link-title="模組六：可靠性驗證流程" data-link-desc="用 SRE 領域詞彙建問題節點、以服務級案例庫累積驗證脈絡，先建概念與案例庫再進實作交接">06 可靠性</a>、<a href="/blog/backend/08-incident-response/" data-link-title="模組八：事故處理與復盤" data-link-desc="用 IR 領域詞彙建問題節點、以服務級案例庫累積事故脈絡，先建概念與案例庫再進實作交接">08 事故處理</a>。重放窗口那一列走 04，因為拒絕原因分不分得開是監控設計的問題。</li>
</ul>
<p>兩條 chain 完成判準與模組級 chain 規格見 <a href="../#%e5%be%9e%e7%ab%a0%e7%af%80%e5%88%b0%e5%af%a6%e4%bd%9c%e7%9a%84-chain">從章節到實作的 chain</a>。</p>
<h2 id="問題節點案例觸發式">問題節點（案例觸發式）</h2>
<table>
  <thead>
      <tr>
          <th>問題節點</th>
          <th>判讀訊號</th>
          <th>風險後果</th>
          <th>前置控制面</th>
          <th>交接路由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>驗證素材定義不一致</td>
          <td>兩端各自解讀欄位順序、單位與空值處理</td>
          <td>對接失敗且錯誤訊息無法定位</td>
          <td><a href="/blog/backend/knowledge-cards/message-authentication/" data-link-title="Message Authentication" data-link-desc="兩個系統用共享密鑰互相呼叫時，用來判斷驗證值保護到什麼範圍、撤銷粒度落在哪一層">message-authentication</a>、<a href="/blog/backend/knowledge-cards/api-contract/" data-link-title="API Contract" data-link-desc="說明 request / response 邊界如何維持相容與可驗證">api-contract</a></td>
          <td><code>05</code></td>
      </tr>
      <tr>
          <td>重放窗口未收斂</td>
          <td>驗證值涵蓋時間戳但接收端未檢查新鮮度</td>
          <td>攔截到的請求可無限重放</td>
          <td><a href="/blog/backend/knowledge-cards/replay-attack/" data-link-title="Replay Attack" data-link-desc="攔截到的合法請求被原封不動再送一次時，用來判斷哪一層該負責擋、以及擋不住會發生什麼">replay-attack</a>、<a href="/blog/backend/knowledge-cards/idempotency-key/" data-link-title="Idempotency Key（冪等鍵）" data-link-desc="同一操作重送時該由誰生成識別碼、存多久、衝突怎麼回——冪等性質的對外契約落地機制">idempotency-key</a></td>
          <td><code>04 + 06 + 08</code></td>
      </tr>
      <tr>
          <td>比對方式抵銷強度</td>
          <td>驗證值用一般字串相等運算比對</td>
          <td>回應時間洩漏吻合前綴的長度</td>
          <td><a href="/blog/backend/knowledge-cards/timing-attack/" data-link-title="Timing Attack" data-link-desc="比對密鑰、token 或簽章的程式碼要判斷是否會由執行時間洩漏資訊時的依據">timing-attack</a></td>
          <td><code>05</code></td>
      </tr>
  </tbody>
</table>
<h2 id="判讀流程">判讀流程</h2>
<ol>
<li>先判這個端點需不需要時間軸的保護：重送一次會不會產生第二次副作用。不會的純查詢端點到這裡就結束，剩下兩項仍然要做。</li>
<li>需要的話定窗口長度。第一題是對方的時間戳在事件產生時簽還是每次送出時簽，它決定下界怎麼算；換算與上下界交叉時的處置見下方「重放窗口的收斂條件」。</li>
<li>接著確認素材規格是雙方共同確認過的一份，而不是各自一份文件。缺的話在第一次對接時補。自助式的供應商沒有那個對話（註冊完就上線、沒有窗口可談），這時退到單方規格：把實際收到的請求原樣存下幾筆當成規格的依據、據此寫回歸測試，並在文件裡標明依據是實測而非對方的文件。這樣做的差別在對方改了東西時測試會紅，而不是等到驗簽開始失敗才發現。</li>
<li>再查程式碼裡比對驗證值那一行用的是不是等時比較函式。</li>
<li>最後把窗口長度、素材規格、拒絕原因的分類與第 4 步的檢查結果寫下來。第 1 步判定為不需要時同樣要寫，記的是判定依據（這個端點重送不產生第二次副作用）——否則判定過而判定不需要，與根本沒判過，在產物上看起來一樣。自己是推送方時這幾項屬於對外契約的內容；自己是接收方時對外契約在對方手上，這幾項落在自己的整合紀錄與監控設定裡。兩種情形共同的最低要求是拒絕原因分得開，事件當下才查得出方向。</li>
</ol>
<h2 id="驗證素材的對齊成本">驗證素材的對齊成本</h2>
<p>素材定義不一致是簽章對接裡反覆發生的耗時來源，特徵是<strong>失敗訊號不具指向性</strong>：驗證值對不起來時只會得到一個布林值，看不出是欄位少了一個、順序反了、還是時間戳的單位是秒而對方送的是毫秒。</p>
<p>這個特徵決定了它的成本結構：耗時不隨團隊能力下降，而是隨介面調整次數重複發生。每一次新增欄位、改變編碼、調整空值處理，兩端都要重新對齊一次，而每一次對齊都要付同樣的排錯成本。因此把素材規格寫進雙方共同的書面契約，這筆投資在整合的生命週期裡會被攤提多次——而寫進去的時機在第一次對接時成本最低，那時兩邊的人都還在同一個對話裡。</p>
<p>規格要涵蓋的項目、可在本機單方完成的診斷手法（先讓兩端各自對同一份輸入算一次，比對中間值而非最終值）與對接的檢查順序見 <a href="/blog/work-log/hmac_signature_field_alignment/" data-link-title="HMAC 簽章對接：對不上的是輸入定義、用確定性反推它落在哪一端" data-link-desc="兩端 HMAC 算不出同一個值時要逐項核對的輸入定義，以及用簽章反推輸入、判斷問題落在哪一端的除錯手法。">HMAC 簽章對接</a>。</p>
<p><strong>驗證素材定義不一致</strong>要有兩個團隊各自實作才會發生，因此它的密度與對接對象數量成正比。跨組織整合、集團內跨產品線、以及自己的服務對接第三方的 webhook 都落在這裡。識別特徵是雙方各有一份描述簽章怎麼算的文件，而沒有一份是雙方共同確認過的。</p>
<h2 id="重放窗口的收斂條件">重放窗口的收斂條件</h2>
<p>哪些端點需要這一步，判準是重送一次會不會產生第二次副作用：轉帳、下單、發送通知、狀態轉移都會，純查詢端點不會。接收外部 webhook 的端點風險最高——請求來自自己控制範圍之外，攻擊者可能就在傳輸路徑上，而 webhook 的重試機制本身就會產生大量重複請求，讓惡意重放藏在正常重試裡。</p>
<p>時間軸條件（驗證值涵蓋時間戳、接收端檢查新鮮度）擋住窗口外的重送，窗口內的重複由識別值去重承接，兩者的分工見 <a href="/blog/backend/knowledge-cards/replay-attack/" data-link-title="Replay Attack" data-link-desc="攔截到的合法請求被原封不動再送一次時，用來判斷哪一層該負責擋、以及擋不住會發生什麼">Replay Attack</a>。這個節點的特殊之處在於缺的那一步從來沒有被排進工作項：驗簽做完了、測試綠了、上線了，而「還要比對時間戳與識別值」這件事沒有出現在任何一張清單上，所以它不是做失敗、是沒有人想到要做。</p>
<p>它的失敗長這樣：webhook 接收端照對方文件實作了簽章驗證，驗簽通過就代表這個請求出自對方，當下沒有人覺得還缺什麼——時間戳與識別值都在 payload 裡，兩者都沒有人比對。某次對方那端重試堆積之後，同一筆扣款被處理了好幾次。查起來每一筆都通過驗簽、每一筆的內容都合法，監控上是一串正常請求；而 webhook 本來就會重試，重複請求與惡意重放在日誌裡沒有任何欄位分得開。</p>
<p>補救的順序由重複落在哪一側決定，而這個案例落在窗口內側：重試堆積發生在幾秒到幾分鐘之內，新鮮度檢查放行它是正確行為，擋得住它的是識別值去重。所以先建去重儲存，再補時間戳的新鮮度檢查把窗口外的重送一併擋掉——兩道控制的分工見上一段。已經發生的重複副作用要逐筆對帳回滾，那部分的工作量由這個缺口存在了多久決定。</p>
<p>窗口長度是對接階段就要定的參數，兩端都能換算成具體的量。下界由對方的時間戳在什麼時候簽決定：送出時簽的話每次重試各自帶新的時間戳，下界就是量測到的 <a href="/blog/backend/knowledge-cards/clock-skew/" data-link-title="Clock Skew" data-link-desc="跨機器比較時間才成立的機制（時效窗口、憑證有效期、事件排序）在決定容忍值時的判斷依據">時鐘偏移</a> 上界加餘裕，落在秒到分鐘級；產生時簽的話整串重試共用同一個時間戳，下界要改取時鐘偏移上界與對方最大重試退避間隔的較大者，而後者通常大上幾個數量級——退避超過窗口的那些合法重試會被判成過期，日誌上與攻擊分不開。這一題要向對方確認，退避表查不到時當作產生時簽並取對方公告的重試總時長，<a href="/blog/backend/knowledge-cards/exponential-backoff/" data-link-title="Exponential Backoff" data-link-desc="說明重試間隔如何逐步拉長以降低下游壓力">指數退避</a> 是它最常見的形狀。</p>
<p>上界由去重的儲存量反推：窗口內的已處理識別值都要留著，請求速率乘上窗口長度就是要保留的筆數。這個數字撐不住時要動的是儲存形態而非窗口——存識別值的雜湊而非原值、讓儲存自己按存活時間淘汰——因為把窗口縮到下界以下會開始拒絕合法的重試，那與攻擊在日誌上分不開。兩端交叉到怎麼調都撐不住時，剩下的路是接受窗口外的重複由業務層的冪等承接，收斂點見 <a href="/blog/backend/knowledge-cards/idempotency-key/" data-link-title="Idempotency Key（冪等鍵）" data-link-desc="同一操作重送時該由誰生成識別碼、存多久、衝突怎麼回——冪等性質的對外契約落地機制">Idempotency Key</a>。涉及金流或不可逆動作的端點直接取下界，不必在區間裡挑。</p>
<p><strong>重放窗口未收斂</strong>出現在接收外部推送的端點，尤其是那些照著對方文件實作驗簽就上線的整合。這一格的成因與其餘節點不同：它不是做錯，是做完之後少做一步，因此它的出現率與驗簽實作的正確性無關——寫得越乾淨的實作越容易停在這裡，因為驗簽那一段看起來已經完成了。識別特徵是 payload 裡帶著時間戳與識別值，而接收端的程式碼裡找不到比對它們的地方。</p>
<h2 id="比對方式抵銷機制強度">比對方式抵銷機制強度</h2>
<p>重算出的值要用等時比較來比對。一般的字串相等運算在第一個不吻合的位元組就回傳，回應時間因此洩漏吻合前綴的長度，攻擊者可以逐位元組推出正確的驗證值，收斂點見 <a href="/blog/backend/knowledge-cards/timing-attack/" data-link-title="Timing Attack" data-link-desc="比對密鑰、token 或簽章的程式碼要判斷是否會由執行時間洩漏資訊時的依據">Timing Attack</a>。</p>
<p><strong>比對方式抵銷強度</strong>出現在自己動手實作驗簽的服務。用對方提供的 SDK 多半不會踩到，因為那一行藏在函式庫裡（多半而非一定——SDK 沒覆蓋到的框架仍要自己寫）；照文件自己寫的會踩到，因為文件多半只說明怎麼算出那個值、不說怎麼比對它。識別動作是查程式碼裡比對驗證值那一行。</p>
<p>各語言都有現成的等時比較函式（<code>hash_equals</code>、<code>compare_digest</code>、<code>ConstantTimeCompare</code> 這一類），判別方式是查程式碼裡比對驗證值那一行用的是不是那個函式。函式取用不到的環境還有第二條路：把收到的值與自己算出的值各再做一次 HMAC（用一把當場產生的隨機密鑰）之後比對，攻擊者無法預測比對的是什麼，時間差因此不再洩漏前綴長度。它與其餘節點的差別在它完全在自己這一側，不必與對方協調。</p>
<h2 id="常見風險邊界">常見風險邊界</h2>
<ul>
<li>驗證素材沒有雙方共同的書面規格時，代表對接成本會在每次介面調整時重新發生，而那筆成本每次都由兩邊同時付。</li>
<li>端點會產生第二次副作用而時間戳與識別值都沒有被比對時，保護只剩「這個請求出自對方」，攔截到的請求可以無限重放。</li>
<li>窗口長度取得比量測到的時鐘偏移還短時，正常請求會在漂移時被拒，而那個拒絕在日誌裡與攻擊的表徵相同。</li>
<li>監控把「驗證失敗」併成一個計數時，事件當下無法從監控判斷該往哪個方向查。三者指向不同：驗證值不符指向規格或密鑰不一致、時間戳過期指向時鐘偏移、識別值重複則是三者裡唯一需要區分惡意與正常重試的一種——它的絕大多數來源是對方的重試，所以不能只看計數，要查得到來源位址與時間分布。</li>
<li>對方的文件與實際送出的內容不一致而對方不願修文件時，處置要往契約層走：把實測結果寫成雙方確認過的附件，並把自己這一側的實作依據記成「依實測而非依文件」。對方連附件都不給時這條整合帶著一個沒有書面依據的假設上線，期限與重評估條件走 <a href="../security-governance-exception-and-tripwire/">7.14 資安治理例外與 Tripwire</a>。</li>
</ul>
<h2 id="案例觸發參考">案例觸發參考</h2>
<ul>
<li>簽章方案在對外契約上的實際形態： <a href="/blog/backend/11-api-design/cases/webhook-stripe-delivery-contract/" data-link-title="11.C60 Stripe webhooks：at-least-once 加簽章、明文要求 consumer 冪等與不依賴順序" data-link-desc="webhook 對外承諾的教科書樣本：三天重試、重複投遞、no-ordering、簽章驗證的責任在同一頁明文轉移給 consumer">Stripe webhook 投遞契約</a></li>
<li>硬編碼憑證與固定認證路徑： <a href="../red-team/cases/edge-exposure/usaherds-cve-2021-44207-hardcoded-credential/">USAHERDS 2021</a></li>
</ul>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>上游（該不該用共享密鑰簽章）：<a href="../machine-credential-mechanism-selection/">7.34 機器憑證的機制選型</a></li>
<li>機制原理與能力上限：<a href="/blog/backend/knowledge-cards/message-authentication/" data-link-title="Message Authentication" data-link-desc="兩個系統用共享密鑰互相呼叫時，用來判斷驗證值保護到什麼範圍、撤銷粒度落在哪一層">Message Authentication</a></li>
<li>素材的逐項清單與診斷手法：<a href="/blog/work-log/hmac_signature_field_alignment/" data-link-title="HMAC 簽章對接：對不上的是輸入定義、用確定性反推它落在哪一端" data-link-desc="兩端 HMAC 算不出同一個值時要逐項核對的輸入定義，以及用簽章反推輸入、判斷問題落在哪一端的除錯手法。">HMAC 簽章對接</a></li>
<li>密鑰放在哪一格、被誰拿得到：<a href="../cryptographic-primitive-selection/">7.28 密碼學原語選型</a></li>
<li>密鑰怎麼交到對方手上：<a href="../machine-credential-issuance/">7.32 機器憑證的配發</a></li>
<li>密鑰的輪替與回收節奏：<a href="../secrets-and-machine-credential-governance/">7.6 秘密管理與機器憑證治理</a></li>
<li>去重儲存與識別值的設計：<a href="/blog/backend/knowledge-cards/idempotency-key/" data-link-title="Idempotency Key（冪等鍵）" data-link-desc="同一操作重送時該由誰生成識別碼、存多久、衝突怎麼回——冪等性質的對外契約落地機制">Idempotency Key</a></li>
<li>素材規格新增欄位時兩端怎麼同步：本站尚無專章；通用的相容紀律見 <a href="/blog/backend/11-api-design/backward-compatibility-discipline/" data-link-title="11.6 向後相容的變更紀律" data-link-desc="哪些變更算 breaking、相容性檢查放人工還是 CI、檢查粒度怎麼選 — 讓介面變更可審可擋的日常紀律">11.6 向後相容的變更紀律</a>，在那之前的最小做法是把新欄位設成可選、兩端各自先支援「有或沒有都算對」一段時間，再切成必填</li>
</ul>
]]></content:encoded></item><item><title>HMAC 簽章對接：對不上的是輸入定義、用確定性反推它落在哪一端</title><link>https://tarrragon.github.io/blog/work-log/hmac_signature_field_alignment/</link><pubDate>Mon, 27 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/work-log/hmac_signature_field_alignment/</guid><description>&lt;p>呼叫外部系統的 API、對方用 HMAC 簽章驗證呼叫方身分、回 403 但不說明哪個欄位對不上——這篇沿著 HMAC 的實際運算過程，走到兩端必須逐字對齊的輸入項目、以及用確定性反推輸入的除錯手法。範例以 HMAC-SHA256 與 Dart 示範、對照 PHP 接收端；判讀方式與 openssl 參照值不限語言，其他雜湊演算法的結構相同。&lt;/p>
&lt;h2 id="hmac-承擔的責任">HMAC 承擔的責任&lt;/h2>
&lt;p>HMAC 證明兩件事：這個請求出自持有密鑰的人，而且內容從發出到接收沒有被改過。它把「身分」與「完整性」綁在同一個值上，接收方只要用同一把密鑰重算一次，比對結果就能同時驗證兩者。&lt;/p>
&lt;p>HMAC 的作用範圍限於認證。產生的簽章公開可見，訊息本身也照常明文傳輸 —— 它提供的是&lt;strong>認證&lt;/strong>而非&lt;strong>保密&lt;/strong>。想讓內容不可讀，需要的是傳輸層加密或內容加密，那是另一組機制。&lt;/p>
&lt;p>這個定位決定了排錯方向：簽章對不上時，問題落在「雙方對輸入的定義不一致」，而不是「加密解密失敗」。&lt;/p>
&lt;h2 id="演算法實際在做什麼">演算法實際在做什麼&lt;/h2>
&lt;p>HMAC 的呼叫在多數語言只有幾行，底下卻有四個決策點，每一個都是兩端可能對不齊的地方：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">final&lt;/span> &lt;span class="n">key&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">utf8&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">encode&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">secret&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="kd">final&lt;/span> &lt;span class="n">message&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">utf8&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">encode&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;&lt;/span>&lt;span class="si">$&lt;/span>&lt;span class="n">timestamp&lt;/span>&lt;span class="si">$&lt;/span>&lt;span class="n">payload&lt;/span>&lt;span class="s1">&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="kd">final&lt;/span> &lt;span class="n">digest&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Hmac&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">sha256&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">key&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="n">convert&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">message&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="k">return&lt;/span> &lt;span class="n">digest&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">toString&lt;/span>&lt;span class="p">();&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="輸入是位元組序列">輸入是位元組序列&lt;/h3>
&lt;p>雜湊函式接受的是位元組，字串需要先決定編碼方式。&lt;code>utf8.encode&lt;/code> 把這個決定寫死，讓兩端對「同樣的字對應哪些位元組」有共識。&lt;/p>
&lt;p>字串串接同樣要精確。&lt;code>'$timestamp$payload'&lt;/code> 產生的是連續字元，中間沒有分隔符：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&amp;#39;1784878245&amp;#39; + &amp;#39;[]&amp;#39; -&amp;gt; &amp;#39;1784878245[]&amp;#39; -&amp;gt; 12 個位元組&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>對應到 PHP 的 &lt;code>$timestamp . $payload&lt;/code> 是同一件事。這裡常見的落差是引號 —— 後端 dump 出來的 &lt;code>&amp;quot;payload&amp;quot; =&amp;gt; &amp;quot;[]&amp;quot;&lt;/code> 是輸出格式加的引號，實際值只有 &lt;code>[&lt;/code> 和 &lt;code>]&lt;/code> 兩個字元。&lt;/p>
&lt;h3 id="密鑰經過內外兩層衍生">密鑰經過內外兩層衍生&lt;/h3>
&lt;p>HMAC 的定義是巢狀的兩次雜湊：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">HMAC(K, m) = H( (K&amp;#39; XOR opad) || H( (K&amp;#39; XOR ipad) || m ) )&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>其中 &lt;code>K'&lt;/code> 是密鑰補零到雜湊的區塊長度，超過長度的密鑰先雜湊過再用；&lt;code>ipad&lt;/code> 是位元組 &lt;code>0x36&lt;/code> 重複填滿一個區塊，&lt;code>opad&lt;/code> 是 &lt;code>0x5c&lt;/code>。區塊長度隨演算法而定，SHA-256 是 64 位元組，SHA-512 是 128 —— 換演算法時這個常數要跟著換，否則算出的值會與函式庫對不上。&lt;/p>
&lt;p>手工實作可以驗證這個結構：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kt">String&lt;/span> &lt;span class="n">hmacByHand&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">List&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="kt">int&lt;/span>&lt;span class="o">&amp;gt;&lt;/span> &lt;span class="n">key&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">List&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="kt">int&lt;/span>&lt;span class="o">&amp;gt;&lt;/span> &lt;span class="n">message&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="kd">const&lt;/span> &lt;span class="n">blockSize&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="m">64&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="kd">final&lt;/span> &lt;span class="n">normalizedKey&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">key&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">length&lt;/span> &lt;span class="o">&amp;gt;&lt;/span> &lt;span class="n">blockSize&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="o">?&lt;/span> &lt;span class="n">sha256&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">convert&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">key&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="n">bytes&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="o">:&lt;/span> &lt;span class="n">key&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="kd">final&lt;/span> &lt;span class="n">paddedKey&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Uint8List&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">blockSize&lt;/span>&lt;span class="p">)..&lt;/span>&lt;span class="n">setAll&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">normalizedKey&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="kd">final&lt;/span> &lt;span class="n">innerKey&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">paddedKey&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">map&lt;/span>&lt;span class="p">((&lt;/span>&lt;span class="n">byte&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="n">byte&lt;/span> &lt;span class="o">^&lt;/span> &lt;span class="mh">0x36&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="n">toList&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="kd">final&lt;/span> &lt;span class="n">outerKey&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">paddedKey&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">map&lt;/span>&lt;span class="p">((&lt;/span>&lt;span class="n">byte&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="n">byte&lt;/span> &lt;span class="o">^&lt;/span> &lt;span class="mh">0x5c&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="n">toList&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="kd">final&lt;/span> &lt;span class="n">innerDigest&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">sha256&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">convert&lt;/span>&lt;span class="p">([...&lt;/span>&lt;span class="n">innerKey&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">...&lt;/span>&lt;span class="n">message&lt;/span>&lt;span class="p">]).&lt;/span>&lt;span class="n">bytes&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">sha256&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">convert&lt;/span>&lt;span class="p">([...&lt;/span>&lt;span class="n">outerKey&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">...&lt;/span>&lt;span class="n">innerDigest&lt;/span>&lt;span class="p">]).&lt;/span>&lt;span class="n">toString&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>跑起來會跟函式庫的輸出逐字相同。&lt;/p>
&lt;h3 id="兩層結構擋掉長度延伸攻擊">兩層結構擋掉長度延伸攻擊&lt;/h3>
&lt;p>直觀的做法是把密鑰接在訊息前面直接雜湊，兩者的輸出完全不同：&lt;/p>
&lt;p>以 &lt;code>secret = 'k'&lt;/code>、&lt;code>message = '1784878245[]'&lt;/code> 為例：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">HMAC(secret, message) -&amp;gt; d436a5cb070ecd04162da2186f7db52b2e365faeefa0492fb8cd176675823124
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">sha256(secret + message) -&amp;gt; c9ac394e856626f834054d4b9747f4e42183db1cf22a157e170fa268bc321d88&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>差異來自安全性而非風格。SHA-256 屬於 Merkle–Damgård 結構，這類雜湊有一個性質：知道 &lt;code>H(secret + m)&lt;/code> 的值、&lt;code>m&lt;/code> 的內容，以及 &lt;code>secret&lt;/code> 的長度，就能在不知道 &lt;code>secret&lt;/code> 本身的前提下，算出 &lt;code>H(secret + m + 補位 + 延伸內容)&lt;/code> 的合法值；長度未知時逐一窮舉，候選數量通常不大。偽造出的訊息會夾帶原本的補位位元組、不是乾淨的尾端追加——這條攻擊在自己的格式下可不可行，看接收端會不會接受補位落在 payload 中間。外層再包一次雜湊切斷了這條路徑。&lt;/p>
&lt;p>理解這點的實務價值在於：對方文件寫 &lt;code>hash_hmac&lt;/code> 或 &lt;code>HMAC&lt;/code> 時，就要用函式庫的 HMAC 實作。自行以 &lt;code>sha256(secret + message)&lt;/code> 拼接算出的值不會與它吻合。&lt;/p>
&lt;h3 id="輸出是小寫十六進位">輸出是小寫十六進位&lt;/h3>
&lt;p>&lt;code>Digest&lt;/code> 內部是 32 個位元組，&lt;code>toString()&lt;/code> 轉成小寫十六進位共 64 個字元。PHP 的 &lt;code>hash_hmac()&lt;/code> 預設輸出同樣格式，所以兩端天然對齊。改用 Base64 或大寫十六進位的系統也存在，對接時先確認對方的輸出格式。&lt;/p>
&lt;p>這個固定長度可以寫進測試當作格式護欄：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="n">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">headers&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s1">&amp;#39;X-Signature&amp;#39;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="n">matches&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">RegExp&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">r&amp;#39;^[0-9a-f]{64}$&amp;#39;&lt;/span>&lt;span class="p">)));&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="兩端必須逐項對齊的輸入">兩端必須逐項對齊的輸入&lt;/h2>
&lt;p>簽章值只呈現吻合或不吻合，不指出差異落在哪一項；對接時把輸入拆成獨立項目逐一確認，比反覆重試有效率。逐項的對象是&lt;strong>簽章素材&lt;/strong>——進入 HMAC 計算的那一串內容（有些文件稱「驗證素材」）。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>項目&lt;/th>
 &lt;th>要確認的內容&lt;/th>
 &lt;th>常見落差&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>素材組成&lt;/td>
 &lt;td>哪些欄位進簽章、順序如何、有無分隔符&lt;/td>
 &lt;td>一端加了分隔符、或多納入一個 header&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>時間戳單位&lt;/td>
 &lt;td>Unix 秒或毫秒&lt;/td>
 &lt;td>語言預設值不同，Dart 與 Java 慣用毫秒&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>空請求的 payload&lt;/td>
 &lt;td>沒有 request body 時，簽章素材那一段填什麼&lt;/td>
 &lt;td>一端用空字串、一端用 &lt;code>[]&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>字元編碼&lt;/td>
 &lt;td>UTF-8 或其他&lt;/td>
 &lt;td>非 ASCII 字元才會顯現，測試資料常是純 ASCII&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>輸出格式&lt;/td>
 &lt;td>十六進位大小寫、或 Base64&lt;/td>
 &lt;td>文件常省略不寫&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="時間戳單位簽章會放行時效檢查才擋">時間戳單位：簽章會放行、時效檢查才擋&lt;/h3>
&lt;p>時間戳的特殊之處：單位錯了，簽章未必揭發它。接收方通常直接拿 header 的原字串進簽章素材——重新格式化會多引入一個要對齊的決策點；於是發送方送毫秒、接收方也用同一串字重算，&lt;strong>簽章吻合&lt;/strong>。真正擋下請求的是另一道時效檢查：毫秒值被當秒解讀會落在數萬年後，任何有效期窗口都不會通過。（接收方若先把時間戳解析成整數、再依自己認定的單位重新格式化才進素材，簽章才會在這一項不吻合。）&lt;/p></description><content:encoded><![CDATA[<p>呼叫外部系統的 API、對方用 HMAC 簽章驗證呼叫方身分、回 403 但不說明哪個欄位對不上——這篇沿著 HMAC 的實際運算過程，走到兩端必須逐字對齊的輸入項目、以及用確定性反推輸入的除錯手法。範例以 HMAC-SHA256 與 Dart 示範、對照 PHP 接收端；判讀方式與 openssl 參照值不限語言，其他雜湊演算法的結構相同。</p>
<h2 id="hmac-承擔的責任">HMAC 承擔的責任</h2>
<p>HMAC 證明兩件事：這個請求出自持有密鑰的人，而且內容從發出到接收沒有被改過。它把「身分」與「完整性」綁在同一個值上，接收方只要用同一把密鑰重算一次，比對結果就能同時驗證兩者。</p>
<p>HMAC 的作用範圍限於認證。產生的簽章公開可見，訊息本身也照常明文傳輸 —— 它提供的是<strong>認證</strong>而非<strong>保密</strong>。想讓內容不可讀，需要的是傳輸層加密或內容加密，那是另一組機制。</p>
<p>這個定位決定了排錯方向：簽章對不上時，問題落在「雙方對輸入的定義不一致」，而不是「加密解密失敗」。</p>
<h2 id="演算法實際在做什麼">演算法實際在做什麼</h2>
<p>HMAC 的呼叫在多數語言只有幾行，底下卻有四個決策點，每一個都是兩端可能對不齊的地方：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">final</span> <span class="n">key</span> <span class="o">=</span> <span class="n">utf8</span><span class="p">.</span><span class="n">encode</span><span class="p">(</span><span class="n">secret</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="kd">final</span> <span class="n">message</span> <span class="o">=</span> <span class="n">utf8</span><span class="p">.</span><span class="n">encode</span><span class="p">(</span><span class="s1">&#39;</span><span class="si">$</span><span class="n">timestamp</span><span class="si">$</span><span class="n">payload</span><span class="s1">&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="kd">final</span> <span class="n">digest</span> <span class="o">=</span> <span class="n">Hmac</span><span class="p">(</span><span class="n">sha256</span><span class="p">,</span> <span class="n">key</span><span class="p">).</span><span class="n">convert</span><span class="p">(</span><span class="n">message</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="k">return</span> <span class="n">digest</span><span class="p">.</span><span class="n">toString</span><span class="p">();</span></span></span></code></pre></div><h3 id="輸入是位元組序列">輸入是位元組序列</h3>
<p>雜湊函式接受的是位元組，字串需要先決定編碼方式。<code>utf8.encode</code> 把這個決定寫死，讓兩端對「同樣的字對應哪些位元組」有共識。</p>
<p>字串串接同樣要精確。<code>'$timestamp$payload'</code> 產生的是連續字元，中間沒有分隔符：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">&#39;1784878245&#39; + &#39;[]&#39;  -&gt;  &#39;1784878245[]&#39;  -&gt;  12 個位元組</span></span></code></pre></div><p>對應到 PHP 的 <code>$timestamp . $payload</code> 是同一件事。這裡常見的落差是引號 —— 後端 dump 出來的 <code>&quot;payload&quot; =&gt; &quot;[]&quot;</code> 是輸出格式加的引號，實際值只有 <code>[</code> 和 <code>]</code> 兩個字元。</p>
<h3 id="密鑰經過內外兩層衍生">密鑰經過內外兩層衍生</h3>
<p>HMAC 的定義是巢狀的兩次雜湊：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">HMAC(K, m) = H( (K&#39; XOR opad) || H( (K&#39; XOR ipad) || m ) )</span></span></code></pre></div><p>其中 <code>K'</code> 是密鑰補零到雜湊的區塊長度，超過長度的密鑰先雜湊過再用；<code>ipad</code> 是位元組 <code>0x36</code> 重複填滿一個區塊，<code>opad</code> 是 <code>0x5c</code>。區塊長度隨演算法而定，SHA-256 是 64 位元組，SHA-512 是 128 —— 換演算法時這個常數要跟著換，否則算出的值會與函式庫對不上。</p>
<p>手工實作可以驗證這個結構：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kt">String</span> <span class="n">hmacByHand</span><span class="p">(</span><span class="n">List</span><span class="o">&lt;</span><span class="kt">int</span><span class="o">&gt;</span> <span class="n">key</span><span class="p">,</span> <span class="n">List</span><span class="o">&lt;</span><span class="kt">int</span><span class="o">&gt;</span> <span class="n">message</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="kd">const</span> <span class="n">blockSize</span> <span class="o">=</span> <span class="m">64</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="kd">final</span> <span class="n">normalizedKey</span> <span class="o">=</span> <span class="n">key</span><span class="p">.</span><span class="n">length</span> <span class="o">&gt;</span> <span class="n">blockSize</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">      <span class="o">?</span> <span class="n">sha256</span><span class="p">.</span><span class="n">convert</span><span class="p">(</span><span class="n">key</span><span class="p">).</span><span class="n">bytes</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">      <span class="o">:</span> <span class="n">key</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="kd">final</span> <span class="n">paddedKey</span> <span class="o">=</span> <span class="n">Uint8List</span><span class="p">(</span><span class="n">blockSize</span><span class="p">)..</span><span class="n">setAll</span><span class="p">(</span><span class="m">0</span><span class="p">,</span> <span class="n">normalizedKey</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="kd">final</span> <span class="n">innerKey</span> <span class="o">=</span> <span class="n">paddedKey</span><span class="p">.</span><span class="n">map</span><span class="p">((</span><span class="n">byte</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">byte</span> <span class="o">^</span> <span class="mh">0x36</span><span class="p">).</span><span class="n">toList</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <span class="kd">final</span> <span class="n">outerKey</span> <span class="o">=</span> <span class="n">paddedKey</span><span class="p">.</span><span class="n">map</span><span class="p">((</span><span class="n">byte</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">byte</span> <span class="o">^</span> <span class="mh">0x5c</span><span class="p">).</span><span class="n">toList</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl">  <span class="kd">final</span> <span class="n">innerDigest</span> <span class="o">=</span> <span class="n">sha256</span><span class="p">.</span><span class="n">convert</span><span class="p">([...</span><span class="n">innerKey</span><span class="p">,</span> <span class="p">...</span><span class="n">message</span><span class="p">]).</span><span class="n">bytes</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">  <span class="k">return</span> <span class="n">sha256</span><span class="p">.</span><span class="n">convert</span><span class="p">([...</span><span class="n">outerKey</span><span class="p">,</span> <span class="p">...</span><span class="n">innerDigest</span><span class="p">]).</span><span class="n">toString</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>跑起來會跟函式庫的輸出逐字相同。</p>
<h3 id="兩層結構擋掉長度延伸攻擊">兩層結構擋掉長度延伸攻擊</h3>
<p>直觀的做法是把密鑰接在訊息前面直接雜湊，兩者的輸出完全不同：</p>
<p>以 <code>secret = 'k'</code>、<code>message = '1784878245[]'</code> 為例：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">HMAC(secret, message)          -&gt; d436a5cb070ecd04162da2186f7db52b2e365faeefa0492fb8cd176675823124
</span></span><span class="line"><span class="ln">2</span><span class="cl">sha256(secret + message)       -&gt; c9ac394e856626f834054d4b9747f4e42183db1cf22a157e170fa268bc321d88</span></span></code></pre></div><p>差異來自安全性而非風格。SHA-256 屬於 Merkle–Damgård 結構，這類雜湊有一個性質：知道 <code>H(secret + m)</code> 的值、<code>m</code> 的內容，以及 <code>secret</code> 的長度，就能在不知道 <code>secret</code> 本身的前提下，算出 <code>H(secret + m + 補位 + 延伸內容)</code> 的合法值；長度未知時逐一窮舉，候選數量通常不大。偽造出的訊息會夾帶原本的補位位元組、不是乾淨的尾端追加——這條攻擊在自己的格式下可不可行，看接收端會不會接受補位落在 payload 中間。外層再包一次雜湊切斷了這條路徑。</p>
<p>理解這點的實務價值在於：對方文件寫 <code>hash_hmac</code> 或 <code>HMAC</code> 時，就要用函式庫的 HMAC 實作。自行以 <code>sha256(secret + message)</code> 拼接算出的值不會與它吻合。</p>
<h3 id="輸出是小寫十六進位">輸出是小寫十六進位</h3>
<p><code>Digest</code> 內部是 32 個位元組，<code>toString()</code> 轉成小寫十六進位共 64 個字元。PHP 的 <code>hash_hmac()</code> 預設輸出同樣格式，所以兩端天然對齊。改用 Base64 或大寫十六進位的系統也存在，對接時先確認對方的輸出格式。</p>
<p>這個固定長度可以寫進測試當作格式護欄：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="n">expect</span><span class="p">(</span><span class="n">headers</span><span class="p">[</span><span class="s1">&#39;X-Signature&#39;</span><span class="p">],</span> <span class="n">matches</span><span class="p">(</span><span class="n">RegExp</span><span class="p">(</span><span class="s1">r&#39;^[0-9a-f]{64}$&#39;</span><span class="p">)));</span></span></span></code></pre></div><h2 id="兩端必須逐項對齊的輸入">兩端必須逐項對齊的輸入</h2>
<p>簽章值只呈現吻合或不吻合，不指出差異落在哪一項；對接時把輸入拆成獨立項目逐一確認，比反覆重試有效率。逐項的對象是<strong>簽章素材</strong>——進入 HMAC 計算的那一串內容（有些文件稱「驗證素材」）。</p>
<table>
  <thead>
      <tr>
          <th>項目</th>
          <th>要確認的內容</th>
          <th>常見落差</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>素材組成</td>
          <td>哪些欄位進簽章、順序如何、有無分隔符</td>
          <td>一端加了分隔符、或多納入一個 header</td>
      </tr>
      <tr>
          <td>時間戳單位</td>
          <td>Unix 秒或毫秒</td>
          <td>語言預設值不同，Dart 與 Java 慣用毫秒</td>
      </tr>
      <tr>
          <td>空請求的 payload</td>
          <td>沒有 request body 時，簽章素材那一段填什麼</td>
          <td>一端用空字串、一端用 <code>[]</code></td>
      </tr>
      <tr>
          <td>字元編碼</td>
          <td>UTF-8 或其他</td>
          <td>非 ASCII 字元才會顯現，測試資料常是純 ASCII</td>
      </tr>
      <tr>
          <td>輸出格式</td>
          <td>十六進位大小寫、或 Base64</td>
          <td>文件常省略不寫</td>
      </tr>
  </tbody>
</table>
<h3 id="時間戳單位簽章會放行時效檢查才擋">時間戳單位：簽章會放行、時效檢查才擋</h3>
<p>時間戳的特殊之處：單位錯了，簽章未必揭發它。接收方通常直接拿 header 的原字串進簽章素材——重新格式化會多引入一個要對齊的決策點；於是發送方送毫秒、接收方也用同一串字重算，<strong>簽章吻合</strong>。真正擋下請求的是另一道時效檢查：毫秒值被當秒解讀會落在數萬年後，任何有效期窗口都不會通過。（接收方若先把時間戳解析成整數、再依自己認定的單位重新格式化才進素材，簽章才會在這一項不吻合。）</p>
<p>所以「簽章算法正確但仍被拒絕」是可能的。排查時把「簽章比對」與「時效檢查」當成兩道獨立關卡，比籠統地懷疑「簽章有問題」更快收斂。</p>
<p>時效檢查本身的窗口該設多寬，取決於兩端的 <a href="/blog/backend/knowledge-cards/clock-skew/" data-link-title="Clock Skew" data-link-desc="跨機器比較時間才成立的機制（時效窗口、憑證有效期、事件排序）在決定容忍值時的判斷依據">時鐘偏移</a> 而非單位換算，那是另一個獨立的參數。</p>
<p>判讀訊號：如果對方的錯誤堆疊在修正某一項之後<strong>換了位置</strong>（例如從第 26 行移到第 46 行），代表通過了前一道檢查、撞上下一道。錯誤位置改變是進度的證據。</p>
<h3 id="空請求的-payload">空請求的 payload</h3>
<p>沒有 request body 的端點，簽章素材裡的 payload 段落取決於接收方怎麼取值：</p>
<ul>
<li>讀原始 body（<code>$request-&gt;getContent()</code>）：空請求得到空字串</li>
<li>讀解析後的參數再序列化（<code>json_encode($request-&gt;all())</code>）：空請求得到 <code>[]</code></li>
</ul>
<p>兩者在文件上都可能被寫成「payload」。想同時滿足兩種實作，可以讓 request body 就是簽章素材本身 —— 送出的位元組與簽名的位元組是同一份，接收方無論用哪種取法都會得到相同結果。</p>
<h2 id="用簽章反推輸入">用簽章反推輸入</h2>
<p>HMAC 是確定性的：同樣的密鑰與訊息永遠產生同樣的輸出。這個性質可以反過來用 —— 手上有對方收到的簽章與時間戳，加上自己的密鑰，就能反解出「送出去的那一刻，實際用的是哪組輸入」。</p>
<p>完整可執行的腳本如下。放進一個空目錄，<code>pubspec.yaml</code> 只需要 <code>crypto</code> 一個依賴，密鑰放同目錄的 <code>.env.local</code>（內容一行 <code>API_SECRET=實際密鑰</code>）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// verify_signature.dart
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">import</span> <span class="s1">&#39;dart:convert&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="k">import</span> <span class="s1">&#39;dart:io&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="k">import</span> <span class="s1">&#39;package:crypto/crypto.dart&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="kt">String</span> <span class="n">sign</span><span class="p">(</span><span class="kt">String</span> <span class="n">secret</span><span class="p">,</span> <span class="kt">String</span> <span class="n">timestamp</span><span class="p">,</span> <span class="kt">String</span> <span class="n">payload</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="kd">final</span> <span class="n">key</span> <span class="o">=</span> <span class="n">utf8</span><span class="p">.</span><span class="n">encode</span><span class="p">(</span><span class="n">secret</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="kd">final</span> <span class="n">message</span> <span class="o">=</span> <span class="n">utf8</span><span class="p">.</span><span class="n">encode</span><span class="p">(</span><span class="s1">&#39;</span><span class="si">$</span><span class="n">timestamp</span><span class="si">$</span><span class="n">payload</span><span class="s1">&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <span class="k">return</span> <span class="n">Hmac</span><span class="p">(</span><span class="n">sha256</span><span class="p">,</span> <span class="n">key</span><span class="p">).</span><span class="n">convert</span><span class="p">(</span><span class="n">message</span><span class="p">).</span><span class="n">toString</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="kt">String</span> <span class="n">readSecret</span><span class="p">(</span><span class="kt">String</span> <span class="n">path</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">  <span class="kd">final</span> <span class="n">line</span> <span class="o">=</span> <span class="n">File</span><span class="p">(</span><span class="n">path</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">      <span class="p">.</span><span class="n">readAsLinesSync</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">      <span class="p">.</span><span class="n">firstWhere</span><span class="p">((</span><span class="n">line</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">line</span><span class="p">.</span><span class="n">startsWith</span><span class="p">(</span><span class="s1">&#39;API_SECRET=&#39;</span><span class="p">));</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">  <span class="k">return</span> <span class="n">line</span><span class="p">.</span><span class="n">split</span><span class="p">(</span><span class="s1">&#39;=&#39;</span><span class="p">).</span><span class="n">sublist</span><span class="p">(</span><span class="m">1</span><span class="p">).</span><span class="n">join</span><span class="p">(</span><span class="s1">&#39;=&#39;</span><span class="p">).</span><span class="n">trim</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">
</span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="kt">void</span> <span class="n">main</span><span class="p">(</span><span class="n">List</span><span class="o">&lt;</span><span class="kt">String</span><span class="o">&gt;</span> <span class="n">args</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">  <span class="kd">final</span> <span class="n">sentTimestamp</span> <span class="o">=</span> <span class="n">args</span><span class="p">[</span><span class="m">0</span><span class="p">];</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">  <span class="kd">final</span> <span class="n">sentSignature</span> <span class="o">=</span> <span class="n">args</span><span class="p">[</span><span class="m">1</span><span class="p">];</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">  <span class="kd">final</span> <span class="n">secret</span> <span class="o">=</span> <span class="n">readSecret</span><span class="p">(</span><span class="s1">&#39;.env.local&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">
</span></span><span class="line"><span class="ln">25</span><span class="cl">  <span class="k">for</span> <span class="p">(</span><span class="kd">final</span> <span class="n">payload</span> <span class="k">in</span> <span class="kd">const</span> <span class="p">[</span><span class="s1">&#39;[]&#39;</span><span class="p">,</span> <span class="s1">&#39;&#39;</span><span class="p">,</span> <span class="s1">&#39;{}&#39;</span><span class="p">,</span> <span class="s1">&#39;null&#39;</span><span class="p">])</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">    <span class="kd">final</span> <span class="n">signature</span> <span class="o">=</span> <span class="n">sign</span><span class="p">(</span><span class="n">secret</span><span class="p">,</span> <span class="n">sentTimestamp</span><span class="p">,</span> <span class="n">payload</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">    <span class="n">print</span><span class="p">(</span><span class="s1">&#39;payload = </span><span class="si">$</span><span class="n">payload</span><span class="se">\t</span><span class="s1">相符: </span><span class="si">${</span><span class="n">signature</span> <span class="o">==</span> <span class="n">sentSignature</span><span class="si">}</span><span class="s1">&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl"><span class="p">}</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">dart pub add crypto
</span></span><span class="line"><span class="ln">2</span><span class="cl">dart run verify_signature.dart <span class="m">1784878245</span> d436a5cb070ecd04162da2186f7db52b2e365faeefa0492fb8cd176675823124</span></span></code></pre></div><p>有一組吻合，就同時證明了兩件事：payload 定義是那一個，而且本機實作與執行中的程式走同一條路徑。它證明不了第三件事——與外部規格相符：自己的兩端可以一致地錯，例如同時漏掉同一個欄位。那要靠對方的測試向量、或語言中立的參照值重現對方的簽章（檢查順序的第二步）。</p>
<p>沒有任何一組吻合，代表變因落在候選集合之外：真實的 payload 取值不在清單裡、素材組成多納入了欄位（路徑、method 或額外 header）、串接順序或分隔符不同，也可能是本機密鑰與執行中的程式不同。順序上先擴大 payload 與素材組成的候選，再懷疑密鑰 —— 密鑰不一致是四種成因裡最少見的一種。</p>
<p>這個手法在編譯期常數的情境特別有用。以 Dart 為例，<code>String.fromEnvironment</code> 的值在編譯時就被寫進產出物，改了設定檔之後若只做 hot reload，執行中的程式仍然帶著舊值 —— 這種狀態在裝置上完全看不出來，卻能被反推立刻辨識。</p>
<p><strong>操作前提</strong>：驗證腳本從設定檔讀密鑰，不從命令列參數傳入。命令列參數會留在 shell 歷史與行程列表裡。</p>
<h2 id="對接時的檢查順序">對接時的檢查順序</h2>
<p>問題可能落在網路層、輸入定義或密鑰本身，由外而內排查能較快縮小範圍：</p>
<ol>
<li>
<p><strong>請求真的到了預期的位置</strong> —— 在送出前把最終組出來的 URL 整串印出來，不是印 path 常數；兩者的差異正是中介層改寫的落點。全域的路徑改寫（語系前綴之類）常會套用到不該套用的外部呼叫上。對方端有存取日誌時，拿他們記錄到的路徑與自己印出的字串逐字比對，一次就能定位。</p>
</li>
<li>
<p><strong>確認演算法與素材組成算得出預期的值</strong> —— 對方提供測試向量時直接重現它。對方沒有提供時，用系統內建的 <code>openssl</code> 建立一個語言中立的參照值：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="nb">printf</span> <span class="s1">&#39;1784878245[]&#39;</span> <span class="p">|</span> openssl dgst -sha256 -hmac <span class="s1">&#39;k&#39;</span></span></span></code></pre></div><p><code>printf</code> 不附加換行，這一點決定結果 —— <code>echo</code> 會多送一個 <code>\n</code>，算出完全不同的值。輸出的 <code>d436a5cb...</code> 拿來與自己程式的輸出比對，相符就證明實作沒問題，範圍收斂到素材定義。這條路徑不限語言，Node、Python、Go 的實作都能用同一個參照值校準。</p>
</li>
<li>
<p><strong>用實際送出的簽章反推輸入</strong> —— 確認執行中的程式用的密鑰與 payload 定義符合預期。</p>
</li>
<li>
<p><strong>前三項都通過仍被拒絕</strong> —— 範圍已收斂到接收方的設定，這時請對方提供四項資料：他們重算出的簽章值、重算所用的素材字串（標出分隔符位置）、他們收到的 timestamp 與 body 原文、以及拒絕的原因碼（簽章不符或時效過期）。這四項對應前文的四個決策點，拿到之後不需要再往返第二輪。</p>
</li>
</ol>
<p>前三項都能在本機完成，不需要對方配合，也不需要反覆重新部署。</p>
<h2 id="判讀與邊界">判讀與邊界</h2>
<p><strong>這篇適用的情境</strong>：串接使用 HMAC 簽章驗證的外部 API、簽章比對失敗且錯誤訊息不具體。</p>
<p><strong>不在範圍內</strong>：密鑰的配發、儲存與輪替機制；簽章之外的授權判斷（呼叫方有沒有權限存取該資源）；HTTPS 傳輸層安全。這些是獨立的設計問題。為什麼這個場景選訊息驗證而不是加密、金鑰該放在哪一端，屬於它上一層的選型判斷，見 <a href="/blog/backend/07-security-data-protection/cryptographic-primitive-selection/" data-link-title="7.28 密碼學原語選型：金鑰位置決定威脅模型" data-link-desc="決定用加密、簽章、編碼還是單向轉換保護一段資料時，用來判斷各原語的保護範圍與失效條件">7.28 密碼學原語選型</a>；在同一批機器憑證機制裡為什麼選它，見 <a href="/blog/backend/07-security-data-protection/machine-credential-mechanism-selection/" data-link-title="7.34 機器憑證的機制選型：秘密要不要在每次呼叫裡送出去" data-link-desc="系統層確定要有獨立的機器身分之後，用來判斷秘密放進網址參數或 header 會落在哪幾跳的紀錄裡、撤銷一次影響誰、以及選定的機制要什麼配套才跑得起來">7.34 機器憑證的機制選型</a>。素材對齊之外的另一個收斂條件（時間戳與識別值要被檢查）與這一篇是同一階段的工作，判讀見 <a href="/blog/backend/07-security-data-protection/signature-integration-verification/" data-link-title="7.35 簽章對接的驗證收斂：驗簽通過之後還缺哪一塊" data-link-desc="接收外部推送或用共享密鑰簽章對接時，用來判斷進入計算的素材要怎麼定義、時間戳窗口要開多寬才擋得住重放、以及比對方式怎麼抵銷機制強度">7.35 簽章對接的驗證收斂</a>；機制本身的責任邊界見 <a href="/blog/backend/knowledge-cards/message-authentication/" data-link-title="Message Authentication" data-link-desc="兩個系統用共享密鑰互相呼叫時，用來判斷驗證值保護到什麼範圍、撤銷粒度落在哪一層">Message Authentication</a>。</p>
<p>自己也是接收方時（雙向對接的情況），比對簽章要用等時比較函式而不是一般的字串相等運算，理由與逐位元組短路造成的洩漏見 <a href="/blog/backend/knowledge-cards/timing-attack/" data-link-title="Timing Attack" data-link-desc="比對密鑰、token 或簽章的程式碼要判斷是否會由執行時間洩漏資訊時的依據">Timing Attack</a>。</p>
<p>對接完成之後，有兩個檢查值得留成測試而不是留在記憶裡。密鑰為空時要輸出明確訊息，否則它會偽裝成後端問題，讓排查方向整個偏掉。時間戳單位要寫成斷言（送出值與現在時間的差距落在合理範圍內），只檢查欄位有值的斷言擋不住單位錯誤 —— 而單位錯誤正是這類對接最常復發的一項。</p>
]]></content:encoded></item></channel></rss>