學習筆記:Pre-signed URL Level 3 — SigV4 拆到骨子裡

jkopay-member · 學習文章 · 2026-07-07 13:35
這是 pre-signed URL 學習路線圖的 Level 3,整條路線的核心。Level 1 講清了「它是什麼、為什麼點了就能存取私有檔卻不用登入、它是 bearer token、權限不超過產生者、產生時純本機運算」;Level 2 分清了「下載用 GET、上傳用 PUT」。這篇只做一件事:X-Amz-Signature 那段簽章的黑盒打開——它到底怎麼算出來的?server 收到又是怎麼驗的?答案就是 SigV4。

Level 1 說 pre-signed URL 是「一個先簽好名的 HTTP 請求」,簽章放在 X-Amz-Signature。但「簽好名」這三個字,一直是個黑盒——那段長長的簽章到底怎麼長出來的?

這一站就是把黑盒拆開。你會看到它其實是三個步驟接力:把請求整理成固定格式的文字 → 用你的 secret key 長出一把「限定用途的簽名筆」→ 拿這把筆在那段文字上簽名。算完的結果就是 X-Amz-Signature

這站技術密度比前兩站高,但我保證每個步驟都給你直覺、不是丟規格書。讀完你要能拿一條真的 pre-signed URL,逐個參數說出意義,畫出「canonical request → signing key → signature」的流程,並解釋為什麼改動 URL 任何一個字元、簽章就會立刻失效

就愛考你

這幾題就是本文的重點,也是讀完你該能用自己的話答出來的問題。先想想現在答不答得出來,然後帶著它們往下讀——遇到答案所在的段落就特別留意;讀完再回來點「看答案」對照。

  1. SigV4 把「簽出一條 pre-signed URL」拆成哪三個步驟接力?請用自己的話串一遍。看答案收合
    • (A) 組 canonical request:把請求抄成固定格式的文字。
    • (B) 推導 signing key + string to sign:對 (A) 做 SHA256、組出四行 string to sign,並用 secret key 做 4 次鏈式 HMAC 長出一把「限定用途的簽名筆」SigningKey。
    • (C) 算 signatureHex(HMAC-SHA256(SigningKey, StringToSign)),再連同其他 X-Amz-* 塞進網址 query string。
  2. 為什麼要大費周章把請求「抄成固定格式的 canonical request」?如果抄的時候差一個空格或參數順序會怎樣?看答案收合
    因為簽章是「對一段文字做雜湊」,而 server 待會兒會照同樣規則再算一次來比對——兩邊的輸入文字必須一個字元都不差,算出來才會相等。差一個空格、大小寫、或參數順序,雜湊就完全不同、簽章對不上。所以 AWS 定死了六行標準格式(method / URI / query(逐個 encode 後按 key 排序)/ headers(小寫排序)/ signed headers / payload 雜湊)。
  3. presigned PUT 的 URL 簽好之後,對方上傳「任何內容」的檔案都能成功。為什麼 body 換了、簽章卻依然成立?看答案收合
    因為 S3 的 canonical request 裡,HashedPayload 這一格填的是固定字串 UNSIGNED-PAYLOAD,而不是去算 body 的雜湊——body 根本沒被簽進去。簽章只綁 method、路徑、被簽的 header。這是特性:讓「先發連結、之後才決定傳什麼」可行;反過來也代表單純的 presigned PUT 無法靠簽章限制上傳內容
  4. 一條 pre-signed URL 裡有一堆 X-Amz-* 參數。為什麼唯獨 X-Amz-Signature 不被算進 canonical request?看答案收合
    因為簽章是「算完」才存在的結果——你不可能在計算之前,就把還沒算出來的答案塞進「要簽的內容」裡(雞生蛋)。所以 CanonicalQueryString 規定要含除了 X-Amz-Signature 以外的所有參數;其他每個 X-Amz-*(Algorithm/Credential/Date/Expires/SignedHeaders)都要一起簽,唯獨簽章自己排除。
  5. 為什麼不直接拿 secret key 簽,而要先繞一圈長出一把「限定用途的簽名筆」(signing key)?這樣做有什麼好處?看答案收合
    因為要做出「作用域受限的衍生金鑰」:把 secret key 一層層揉進「日期 → region → service → aws4_request」(每一層都做一次 HMAC),長出的 SigningKey 只對那一天、那個 region、那個 service 有效。好處是——就算 SigningKey 外洩,攻擊者也只能在那天/那 region/那 service 濫用,換天就是廢筆;而真正的 secret key 從頭到尾沒離開你的機器、沒進網址、沒傳給 server。最後才用這把 SigningKey 對 string to sign(下面 (B) 會講)簽出簽章。
  6. 網址裡帶的是 access key ID 還是 secret key?server 收到請求時,靠什麼知道該用哪組憑證來重算簽章?看答案收合
    帶的是公開的 access key ID(在 X-Amz-Credential 裡,等於「帳號」那半),不是 secret key。server 就是靠 X-Amz-Credential 裡的 access key ID,從自己這邊查出對應的 secret key、長出同一把 SigningKey、算出簽章來比對。secret key 的效力早被揉進 SigningKey、再壓成那段 signature,全程不出現在網址裡。
  7. server 收到一條 pre-signed URL 請求時,到底驗哪三件事?為什麼「改動 URL 任一參數簽章就失效」、而攻擊者又補不了正確簽章?看答案收合
    三件事:(1) 用 URL 參數重算簽章跟 X-Amz-Signature 比對(不符→SignatureDoesNotMatch);(2)X-Amz-Date + X-Amz-Expires請求當下判斷是否過期;(3) 那組憑證此刻是否仍有效仍有權限(撤銷/停用/臨時憑證 session 過期都會讓沒到期的 URL 失效)。
    改任一被簽的參數→server 重算的輸入變了→結果對不上原簽章。攻擊者補不了,是因為算正確簽章要有正確 SigningKey,而 SigningKey 要用 secret key 才長得出來,secret key 不在他手上。
動工前先花 10 秒撿回兩個 Level 0 的工具——本站從頭到尾一直用到它們: 有這兩句墊底,後面所有 雜湊 / SHA256 / HMAC 都有靠山。

先看全景:SigV4 = 三個步驟接力

整套演算法就三塊,AWS 官方把它拆成這樣:

先看下面這張流程圖抓個輪廓——圖上每個生字接下來都會一個一個拆開,這裡先掃過、留個印象就好

flowchart TD A["請求要素
method / URI / query / headers / payload"] --> B["(A) Canonical Request
六行固定格式字串"] B --> C["SHA256 → 一串 hex"] C --> D["(B) String to Sign
演算法 + 時間 + credential scope + 上步 hex"] SK["Secret Key"] --> E["4 次鏈式 HMAC
date → region → service → aws4_request"] E --> F["Signing Key
(限日期/region/service)"] D --> G["(C) signature =
HMAC-SHA256(SigningKey, StringToSign)"] F --> G G --> H["連同 X-Amz-Algorithm / Credential / Date /
Expires / SignedHeaders 塞進 query string"]
比喻先給你抓感覺:想像你要寄一份「防偽公文」。
(A) 先把公文內容謄寫成一份格式嚴格、一個字都不能差的正本;
(B) 你手上有一顆總印章(secret key),但你不直接蓋它——你先用它刻一顆「只有今天、只有這個部門能用」的臨時章;
(C) 拿臨時章蓋在正本上,那個印記就是簽章。
收件方拿到公文,會照同樣格式再謄一次、也刻同一顆臨時章、再蓋一次,兩個印記一比對,對得上就收、對不上就退。

下面把三塊一塊一塊拆。

(A) Canonical Request:把請求「抄成同一種寫法」

這一步的唯一目的:讓你算的東西,跟 server 待會兒重算的東西,一個字元都不差。

為什麼要這麼龜毛?因為簽章是「對一段文字做雜湊」。文字差一個空格、一個大小寫、參數順序換一下,算出來的簽章就完全不同。 就像兩個人各自算同一道長算式,中間步驟必須一模一樣,答案才會相等。所以 AWS 定死了一套「標準抄寫法」,叫 canonical request。

它是六行文字、用換行接起來

HTTPMethod            ← GET / PUT / HEAD / DELETE …
CanonicalURI          ← 物件路徑,如 /my-bucket/photo.jpg
CanonicalQueryString  ← 所有 query 參數,逐個 URL-encode、按 key 排序
CanonicalHeaders      ← 至少要有 host,小寫、按字母排序
SignedHeaders         ← 上面被納入簽章的 header 清單,分號連接
HashedPayload         ← body 的雜湊

逐行講白:

把六行填上真實值,看它到底長什麼樣

骨架抽象,直接填一份實例最清楚。本站全程用 path-style 定址(bucket 放在路徑裡、host 是 s3.amazonaws.com;另一種 virtual-hosted 定址是 Level 6 的題目,這裡不展開)。以「下載 my-bucket 裡的 photo.jpg、有效 900 秒」為例,canonical request 六行實際長這樣:

GET
/my-bucket/photo.jpg
X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20260707%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260707T091100Z&X-Amz-Expires=900&X-Amz-SignedHeaders=host
host:s3.amazonaws.com

host
UNSIGNED-PAYLOAD

對著六行看,幾個關鍵處:

那第 4 行跟 host(第 5 行)之間怎麼空了一行? 這不是排版失誤,是規格。CanonicalHeaders 這一塊,每個 header 都寫成 name:value、而且每行結尾都帶一個換行——所以 host:s3.amazonaws.com 這行本身就以換行收尾了;接著「六行用換行接起來」的規則,又在 CanonicalHeaders 和 SignedHeaders 之間再加一個換行。兩個換行疊在一起,看起來就是中間空了一行。照著重建 canonical request 時,這個空行千萬不能省,否則簽章就對不上。

第六行 HashedPayload 填的是 UNSIGNED-PAYLOAD——這藏著一個 pre-signed URL 的關鍵設計,單獨拉出來講。

關鍵一:HashedPayload 用 UNSIGNED-PAYLOAD——這就是為什麼上傳換內容簽章仍成立

一般帶 body 的請求,HashedPayload 會是 Hex(SHA256(body))——把 body 算進簽章。但 pre-signed URL 產生的當下,根本還沒有 body(尤其上傳:你產一條 PUT URL 給對方時,你完全不知道他等一下要傳什麼檔)。

所以 S3 有個專屬做法:canonical request 裡的 HashedPayload 直接填固定字串 UNSIGNED-PAYLOAD,不去算 body。AWS 原話:

For Amazon S3, include the literal string UNSIGNED-PAYLOAD when constructing a canonical request。
這就解開了 Level 2 留下的一個疑問:為什麼 presigned PUT 的 URL 簽好之後,對方上傳「任何內容」的檔案都能成功?因為 body 根本沒被簽進去——簽章只綁了 method、路徑、被簽的 header,沒綁 body。這是特性、不是漏洞:它讓「先發連結、之後才決定傳什麼」變得可行;相對地,也代表單純的 presigned PUT 無法靠簽章限制上傳內容(要限制內容是 Level 5/7 的題目)。

關鍵二:CanonicalQueryString 要含「除了 X-Amz-Signature 以外」的所有參數

pre-signed URL 是把簽章放進 query string 的(不是放 header)。這帶出一個雞生蛋問題:簽章本身也是一個 query 參數(X-Amz-Signature),那它要不要被算進 canonical request?

答案:不要。AWS S3 API 規定,CanonicalQueryString 必須包含除了 X-Amz-Signature 以外的所有 query 參數。

道理很單純:簽章是「算完」才存在的結果,你不可能在計算之前就把還沒算出來的答案塞進要簽的內容裡。 其他每個 X-Amz-*(Algorithm、Credential、Date、Expires、SignedHeaders)都要進去一起簽;唯獨簽章自己排除在外(這也是為什麼上面那份填實值的第 3 行看不到它)。

(B) 第二步:String to Sign 與 Signing Key

canonical request 組好之後,先對它做一次 SHA256,得到一串小寫 hex(十六進位,就是用 0-9a-f 這 16 個字元把那串位元組寫成一列可讀字串)。然後組出 string to sign——四行文字,換行接起,結尾不換行

AWS4-HMAC-SHA256                        ← 演算法
20260707T091100Z                        ← RequestDateTime(ISO8601, UTC)
20260707/us-east-1/s3/aws4_request      ← Credential Scope
<hex(SHA256(canonical request))>        ← 上面那串 hex

逐行白話:

為什麼不直接拿 secret key 簽?先長出一把「限定用途的筆」

直覺上你可能以為:拿 secret key 對 string to sign 做一次 HMAC 就是簽章了。不是。 SigV4 多繞了一圈:先用 secret key 做 4 次鏈式 HMAC-SHA256,推導出一把 signing key,再用這把 signing key 去簽。AWS 的確切公式:

DateKey              = HMAC-SHA256("AWS4" + SecretKey, "20260707")
DateRegionKey        = HMAC-SHA256(DateKey,             "us-east-1")
DateRegionServiceKey = HMAC-SHA256(DateRegionKey,       "s3")
SigningKey           = HMAC-SHA256(DateRegionServiceKey,"aws4_request")

看懂它在幹嘛:把 secret key 一層一層揉進「日期 → region → service → aws4_request」,每一步的輸出當作下一步的 key。最後得到的 SigningKey,就是那把「限定用途的簽名筆」。

這個「作用域受限的衍生金鑰」設計,才是重點 比喻:與其把「總鑰匙」交出去,不如用總鑰匙刻一把「只能開今天這個房間」的臨時鑰匙。臨時鑰匙掉了,損失有限;總鑰匙始終在你手上。

(C) 第三步:算出 signature,塞進 query string

有了 SigningKey 和 string to sign,最後一步就一行:

signature = Hex( HMAC-SHA256(SigningKey, StringToSign) )

用 SigningKey 當 key、對 string to sign 做 HMAC-SHA256,把結果轉成小寫 hex——這就是 X-Amz-Signature 的值。

然後把它連同其他 X-Amz-* 參數全部塞進網址的 query string。承接上面那份 path-style 實例,一條 pre-signed URL 拆開長這樣(為了讀,這裡換行、沒 URL-encode;真實網址是連續一串、且已編碼):

https://s3.amazonaws.com/my-bucket/photo.jpg?
X-Amz-Algorithm=AWS4-HMAC-SHA256&
X-Amz-Credential=AKIAIOSFODNN7EXAMPLE/20260707/us-east-1/s3/aws4_request&
X-Amz-Date=20260707T091100Z&
X-Amz-Expires=900&
X-Amz-SignedHeaders=host&
X-Amz-Signature=fe5f80f77d5fa3beca038a248ff027d0445342fe2855 ...

對照一下就知道 (A) 的 canonical request 是怎麼從這條 URL 重建的:host 是 s3.amazonaws.com、CanonicalURI 是 /my-bucket/photo.jpg——兩者對得起來。每個參數的意義:

Query 參數意義
X-Amz-Algorithm簽章演算法,固定 AWS4-HMAC-SHA256
X-Amz-Credentialaccess key ID / 日期 / region / service / aws4_request——即你的 access key ID 加上 credential scope。注意:帶的是公開的 access key ID,不是 secret key
X-Amz-Date簽章時間(ISO8601, UTC),驗證期限的基準點
X-Amz-Expires有效秒數(相對 X-Amz-Date)。這是 pre-signed URL 的識別特徵。最大 604800 秒(= 7 天,用 IAM user 長期憑證時);S3 console 產的是 1 分鐘~12 小時
X-Amz-SignedHeaders哪些 header 被納入簽章(至少 host
X-Amz-Signature上面算出的簽章本體。唯一真正機密的東西,也是唯一沒被算進 canonical request 的參數
把 Level 1 的「access key ID vs secret key」在這裡收口:網址裡的 X-Amz-Credential 帶了 access key ID(公開的那半,等於「帳號」),這是要讓 server 知道「該用哪組憑證來重算」。而 secret key(真正的秘密)從頭到尾沒出現在網址裡——它的效力早被「揉」進 SigningKey、再壓成那段 signature 了。

server 端到底驗什麼

URL 產好了、發出去了。等對方真的拿它去存取物件時,S3 / MinIO 才出場,做三件事:

1. 重算簽章、逐字比對。 server 從 URL 裡把 method、路徑、query 參數(除了 X-Amz-Signature)、SignedHeaders、host 全撿出來,照 SigV4 完整再算一次(它認得 X-Amz-Credential 裡的 access key ID,就從自己這邊查出對應的 secret key、長出同一把 SigningKey、算出簽章)。算出來的跟網址裡的 X-Amz-Signature 一比:

等等——Level 1/(B) 段不是說 secret key「從沒離開你的機器、沒進網址、沒傳 server」嗎?那 server 哪來的 secret 拿來重算?
關鍵在 HMAC 是「對稱式」的(回扣 Level 0):同一把 secret,兩邊各算一次、比對是否一致——這代表驗證方本來就得手上有同一把 secret。而這把 secret,server 端本來就存有一份副本:你當初建立這組 access key 時,access key IDsecret key 就是在 AWS/MinIO 那邊「成對」產生、成對保存的。所以 server 靠 URL 裡的 access key ID 一查,就拿得到對應的 secret,根本不需要你這次把 secret 傳過去
換句話說,「網址沒帶 secret」的精確意思是 「不透過這次請求傳輸」不是「server 不知道」。secret key 只是不在傳輸途中露臉,並非無人知曉。

2. 檢查是否過期。X-Amz-Date + X-Amz-Expires 為準,在收到請求的當下判斷。AWS 原話:

Amazon S3 checks the expiration date and time of a signed URL at the time of the HTTP request。若 client 在到期前「開始」下載一個大檔,就算下載途中過了期限,下載仍會繼續;但若連線中斷、client 在過期後想重連,就會失敗。

3. 檢查那組憑證此刻仍有效、仍有權限。 這一條和「有沒有到期」是兩回事:

為什麼改動 URL 任何一個參數,簽章都會失效

這是這一站最該內化的結論,也是過關的核心。把上面串起來就懂了:

簽章 = 對「請求內容」做的雜湊。你一改請求裡任何被簽進去的東西——method、物件路徑、任一個 X-Amz-* 參數、被簽的 header——server 重算時的輸入就變了,算出來的簽章自然跟網址裡原本的 X-Amz-Signature 對不上,於是 SignatureDoesNotMatch

那攻擊者能不能自己「補簽」一個對得上的?不能。 因為要算出正確簽章,必須有正確的 SigningKey,而 SigningKey 要用 secret key 才長得出來——secret key 不在網址裡、也不在攻擊者手上(只有你和 server 兩邊有)。這就是為什麼「改一個字元就壞」不是麻煩,而是這套機制的安全保證:它從數學上鎖死了「這條 URL 只能做當初簽的那一件事」。

(順帶一提,這也解釋了 Level 5 會展開的兩個常見雷:時鐘偏移——產 URL 的機器時間跟 server 差太多會直接 SignatureDoesNotMatch 或提早過期,務必 NTP 對時;以及代理伺服器——中間的 proxy 若偷改了 header 或 query string,也會讓 server 重算對不上。)

收尾:一口氣把三塊串起來

拿到一條 pre-signed URL,你現在應該能講完整條鏈:

✔ 過關標準:能講出這五點、並說清「為什麼改一個字元就壞」,這一站就過關了。

這站刻意不碰的(之後的站)

為了聚焦,下面這些點名 defer:

這站只負責一件事:把 SigV4 的簽章與驗證,從骨子裡講清楚。

參考來源(實際爬過的權威連結)