一枚沒人能複製的火漆印
幾百年前,國王會用一團融化的火漆封住信件,再把他的印戒壓上去。任何人一眼看到這枚印,就能確定兩件事:這封信來自國王,而且一路上沒人拆過它。要想偷看裡頭的內容就得弄破火漆,而一旦弄破,就再也封不出一模一樣的印。密碼雜湊就是這枚印章的數位版——只不過壓上去的不是戒指對火漆,而是一串算術運算壓在你的資料上。
雜湊函式能吃下任何輸入——一個單字、一部 4 GB 的電影、人類寫過的所有書的全文——再回傳一串固定長度的短字串,稱為摘要(digest,或就叫雜湊值)。餵它同一個輸入,你永遠會拿到同一個摘要,每次都一樣,在地球上任何一臺機器上都一樣。把輸入改動一個逗號,摘要就會徹底改變。那串短字串,就是你資料的指紋:短到可以隨手抄下,卻又和原資料綁得如此緊密,以至於至今沒有人能找出兩個不同的輸入,共用同一枚指紋。
好的雜湊立下的五個承諾
不是每個能把資料壓小的函式,都安全到足以拿來承載金錢。用來抓出信用卡號打錯一位的那種校驗碼,也算是一種雜湊,但你幾秒鐘就能偽造出一個對得上的號碼。密碼雜湊被要求達到高得多的標準——尤其是以下五個承諾:
- 確定性。 同一個輸入永遠得出同一個摘要。裡頭沒有隨機性、不看時鐘、沒有藏起來的鹽值——給定那串位元組,答案就是固定的。
- 固定且精簡的長度。 一個字母還是一整座圖書館,摘要長度都一樣(SHA-256 是 256 位元)。指紋不會隨資料變大而變長。
- 正向計算很快。 即使是大檔案,算雜湊也只需幾毫秒。困難從來不在做出一枚摘要——而在於從摘要反推回去。
- 單向(原像抗性)。 給你一枚摘要,沒有捷徑能反推出產生它的輸入。你唯一的辦法,是不斷猜輸入、做雜湊,直到撞中一個——而要試的數量多到天文等級。
- 碰撞抗性。 沒人能找出兩個不同的輸入,雜湊到同一個摘要。理論上碰撞一定存在(輸入無限多、摘要有限),但要找到哪怕一組,都必須昂貴到絕望。
藏在最後那條性質裡的,還有第六個近乎魔法的行為,叫做雪崩效應(avalanche effect):翻動輸入的一個位元,平均而言輸出也會有一半的位元跟著翻轉——而且翻得無法預測。這裡沒有什麼平滑的旋鈕,讓輸入小改一點、摘要就只偏移一點。任何改動,無論多微小,都會把整枚指紋甩進一個全新、看似隨機的樣貌。這正是我們接下來要親眼見證的事。
親眼看一場雪崩
我們用 SHA-256 雜湊幾個短字串,讀讀真正的摘要。這些指令你都能在任何 Mac 或 Linux 終端機上自己跑一遍——下面的數字不是我編的,正是這個函式真實回傳的結果。
$ printf 'hello' | shasum -a 256 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 $ printf 'Hello' | shasum -a 256 # only the 'h' became 'H' 185f8db32271fe25f561a6fc938b2e264306ec304eda518007d1764826381969 $ printf 'hello.' | shasum -a 256 # added a single period 1589999b0ca6ef8814283026a9f166d51c70a910671c3d44049755f07f2eb910 $ printf '' | shasum -a 256 # the empty string still has a hash e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
仔細看 `hello` 對上 `Hello`。我們只改了恰好一個位元——小寫 `h` 是位元組 `0x68`,大寫 `H` 是 `0x48`,差在一個二進位數字。然而兩串摘要完全看不出任何關聯。前者開頭是 `2cf24dba…`,後者是 `185f8db3…`。你無法看著其中一個,去猜出另一個的半點線索。這就是雪崩效應:沒有「接近」、沒有「越來越熱/越來越冷」。一枚摘要,要嘛分毫不差地對上,要嘛就只是一團雜訊。
SHA-256 內部:是算術,不是魔法
雪崩看起來像巫術,但 SHA-256 其實是一份固定、公開、人人都能實作的食譜——裡頭沒有任何祕方。它屬於一類稱為 Merkle–Damgård 的設計,會把訊息一次咀嚼 512 位元一塊,把每一塊都攪進一個持續更新的內部狀態裡。整個流程的高層概覽如下:
- 填補訊息。 先補上一個 `1` 位元,再補足夠多的 `0` 位元,最後接上一個記錄原始長度的 64 位元數字,使總長度剛好是 512 位元的整數倍。(把長度也填進去,正是空字串也有豐富摘要的原因。)
- 從八個固定的字開始。 把八個 32 位元暫存器(叫它們 a…h)初始化為一組常數,這些常數取自前八個質數的平方根的小數部分。
- 展開每一塊。 把一塊裡的 16 個字,透過位元旋轉與 XOR 混合先前的字,擴展成 64 個字。
- 跑 64 輪。 每一輪都用加法、旋轉、XOR,以及非線性的 `Ch` 與 `Maj` 函式,再加上一個輪常數(取自前 64 個質數的立方根),把八個暫存器攪一遍。一個被改動的位元,就是在這裡擴散到所有地方的。
- 串接並收尾。 把每輪的結果加回持續更新的狀態裡,再處理下一塊。最後一塊跑完後,把八個暫存器串接起來——那串 256 位元,就是你的摘要。
H = INITIAL_EIGHT_WORDS # from sqrt of primes
for block in pad(message).blocks_of_512_bits():
w = expand(block) # 16 words -> 64 words
a,b,c,d,e,f,g,h = H
for t in range(64): # 64 rounds of stirring
T1 = h + Sigma1(e) + Ch(e,f,g) + K[t] + w[t]
T2 = Sigma0(a) + Maj(a,b,c)
h,g,f,e = g, f, e, (d + T1)
d,c,b,a = c, b, a, (T1 + T2)
H = [ (x + y) mod 2**32 for x,y in zip(H, [a,b,c,d,e,f,g,h]) ]
digest = concat(H) # 8 x 32 bits = 256-bit fingerprint2^256 這道牆——以及 SHA-1 倒下的那天
為什麼反推雜湊是絕望的?因為 SHA-256 有 2^256 種可能的輸出,而這個數字大到幾乎難以想像:約 1.16 × 10^77——和可觀測宇宙裡的原子數量相當。要攻破原像抗性,你只能不停猜輸入、做雜湊,直到撞中目標。就算一臺機器每秒能試一兆兆(10^24)次,平均下來,要命中哪怕一次,所需的時間都遠遠超過宇宙的年齡。
找碰撞比找原像稍微容易些,這要歸功於生日悖論:一個房間裡只要有 23 個人,大概就會有兩人同一天生日,因為你只需要任意一對對上即可。同樣的數學意味著:一個 n 位元的摘要,大約 2^(n/2) 次嘗試就能撞出碰撞,而不是 2^n。所以 SHA-256 的碰撞抗性大約值 2^128 的計算量——仍約 3.4 × 10^38,對任何人來說都遠在能力之外。
但「今天很難」不等於「永遠很難」,而較弱的雜湊確實已經倒下。MD5 被攻破得如此徹底,以致 2012 年的 Flame 間諜惡意程式,利用一次 MD5 碰撞偽造了一張微軟的程式碼簽章憑證。SHA-1 撐得久一些,但 2017 年 2 月,Google 與阿姆斯特丹的 CWI 公布了 SHAttered:兩個不同的 PDF 檔案,竟有相同的 SHA-1 摘要,是用約 2^63 次計算找到的——昂貴,但不再是科幻小說。這正是為什麼正經系統都改用了 SHA-256 及其同族,也是為什麼誠實的工程師會說某個雜湊「就我們目前所知是安全的」,而絕不會說「永遠安全」。
從一枚指紋,到一段無法竄改的歷史
現在來收割成果。區塊鏈,本質上是一疊區塊,其中每個區塊標頭都儲存了前一個區塊的雜湊值。每個區塊都用一枚指紋封印,而這枚指紋同時取決於它自己的內容以及前一個區塊的雜湊,於是區塊被串成一條鏈:第 100 塊的指紋被烤進第 101 塊裡,第 101 塊的指紋又被烤進第 102 塊,如此一路回溯,直到最初的那一塊。
假設有個賊想悄悄改掉埋在第 100 塊裡的一筆付款——比方說,把他收到的金額調高。哪怕只改一個數字,都會改變第 100 塊的內容,而這(雪崩!)會把它的指紋變成毫不相干的東西。但第 101 塊裡記的還是舊的指紋,於是這條鏈會明顯斷開。要藏住這次竄改,賊就必須重新封印第 100 塊,並且重算其後的每一塊,好讓所有指紋重新對得上。此外,每一塊還透過一棵 Merkle 樹承諾了它所有的交易,所以連在區塊內部偷偷動手腳,都瞞不過那枚印。
這就是整個地基,而你現在已經握住了它。雜湊是一枚你偽造不了、反推不了、也撞不出碰撞的指紋——小到可以隨手傳遞,卻又敏感到一個位元翻轉就把它整個改寫。把這些指紋疊成一條鏈,你就得到一段會自己揭發竄改的歷史。下一篇,我們就用這把同樣的工具來搭建 Merkle 樹——正是這個結構,讓一支手機不必下載整個區塊,也能驗證某一筆付款確實在區塊之中。