第 1 章說明過,加密方案把置換與運作模式結合起來,以處理任意長度的訊息。本節涵蓋區塊密碼的主要運作模式、它們的安全與功能性質,以及該(或不該)如何使用它們。

我們從最蠢的一個開始:電子碼本。

ECB 模式(電子碼本)#

電子碼本(ECB,electronic codebook)是最簡單的區塊密碼加密模式——簡單到幾乎稱不上是一種運作模式。

ECB 接收明文區塊 P₁, P₂, ..., Pₙ,各自獨立處理:

C₁ = E(K, P₁)
C₂ = E(K, P₂)
...

圖 4-6:ECB 模式

操作簡單,但不安全

看得見那隻企鵝#

微軟的密碼學家 Marsh Ray 說過:「大家都知道 ECB 模式很糟,因為我們看得見那隻企鵝。」

他指的是一個著名的 ECB 不安全性示範:拿 Linux 吉祥物 Tux 的圖片,用 ECB 模式的 AES 加密(底層用什麼密碼並不重要)。加密後的版本仍然能輕易看出企鵝的形狀——因為原圖中所有同一灰階的區塊,在新圖中都被加密成同一種新的灰階。換句話說,ECB 加密只是給了你同一張圖,只是換了顏色。

圖 4-7:原圖(左)與 ECB 加密後的圖(右)

用程式驗證#

下面的 Python 程式挑一把偽隨機金鑰,加密一段包含兩個全零區塊的 32 位元組訊息:

#!/usr/bin/env python

from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
from binascii import hexlify as hexa
from os import urandom

BLOCKLEN = 16
def blocks(data):
    split = [hexa(data[i:i+BLOCKLEN]) for i in range(0, len(data), BLOCKLEN)]
    return ' '.join(split)

k = urandom(16)
print "k = %s" % hexa(k)

# create an instance of AES-128 to encrypt and decrypt
cipher = Cipher(algorithms.AES(k), modes.ECB(), backend=default_backend())
aes_encrypt = cipher.encryptor()

# set plaintext block p to the all-zero string
p = '\x00'*BLOCKLEN*2

# encrypt plaintext p to ciphertext c
c = aes_encrypt.update(p) + aes_encrypt.finalize()
print "enc(%s) = %s" % (blocks(p), blocks(c))

執行結果:

$ ./aes_ecb.py
k = 50a0ebeff8001250e87d31d72a86e46d
enc(00000000000000000000000000000000 00000000000000000000000000000000) =
5eb4b7af094ef7aca472bbd3cd72f1ed 5eb4b7af094ef7aca472bbd3cd72f1ed

使用 ECB 模式時,相同的密文區塊向攻擊者洩漏了相同的明文區塊——無論這些區塊是在同一段密文之內,還是跨越不同的密文。

這顯示 ECB 模式的區塊密碼不具語意安全性

ECB 的另一個問題是它只接受完整的資料區塊。若區塊是 16 位元組(如 AES),你就只能加密 16、32、48 位元組,或任何 16 的倍數。

有幾種方法可以處理這個問題,下一節的 CBC 會談到。(這裡不說明這些技巧怎麼套用在 ECB 上,因為你根本就不該使用 ECB。)

CBC 模式(密碼區塊鏈接)#

密碼區塊鏈接(CBC,cipher block chaining)像 ECB,但有一個造成巨大差異的小轉折:它不是把第 i 個區塊 Pᵢ 加密成 Cᵢ = E(K, Pᵢ),而是:

Cᵢ = E(K, Pᵢ ⊕ C(i−1))

其中 C(i−1) 是前一個密文區塊——因而把 C(i−1)Cᵢ 鏈接起來。

加密第一個區塊 P₁ 時沒有前一個密文區塊可用,所以 CBC 取一個隨機的初始值(IV,initial value)。

圖 4-8:CBC 模式

兩個好處#

  • CBC 讓每個密文區塊依賴於所有先前的區塊,確保相同的明文區塊不會產生相同的密文區塊。
  • 隨機的初始值保證:用兩個相異的初始值呼叫兩次,兩段相同的明文會加密成相異的密文。

下面的程式加密一段全零的 32 位元組訊息兩次,每次都用新的隨機 IV:

#!/usr/bin/env python

from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
from binascii import hexlify as hexa
from os import urandom

BLOCKLEN = 16
# the blocks() function splits a data string into space-separated blocks
def blocks(data):
    split = [hexa(data[i:i+BLOCKLEN]) for i in range(0, len(data), BLOCKLEN)]
    return ' '.join(split)
k = urandom(16)
print "k = %s" % hexa(k)
# pick a random IV
iv = urandom(16)
print "iv = %s" % hexa(iv)
# pick an instance of AES in CBC mode
aes = Cipher(algorithms.AES(k), modes.CBC(iv), backend=default_backend()).encryptor()

p = '\x00'*BLOCKLEN*2
c = aes.update(p) + aes.finalize()
print "enc(%s) = %s" % (blocks(p), blocks(c))
# now with a different IV and the same key
iv = urandom(16)
print "iv = %s" % hexa(iv)
aes = Cipher(algorithms.AES(k), modes.CBC(iv), backend=default_backend()).encryptor()
c = aes.update(p) + aes.finalize()
print "enc(%s) = %s" % (blocks(p), blocks(c))

兩段明文相同(都是兩個全零區塊),但加密後的區塊應該相異:

$ ./aes_cbc.py
k = 9cf0d31ad2df24f3cbbefc1e6933c872
iv = 0a75c4283b4539c094fc262aff0d17af
enc(00000000000000000000000000000000 00000000000000000000000000000000) =
370404dcab6e9ecbc3d24ca5573d2920 3b9e5d70e597db225609541f6ae9804a
iv = a6016a6698c3996be13e8739d9e793e2
enc(00000000000000000000000000000000 00000000000000000000000000000000) =
655e1bb3e74ee8cf9ec1540afd8b2204 b59db5ac28de43b25612dfd6f031087a

常見誤用:固定 IV#

遺憾的是,CBC 常常被搭配固定的 IV 而非隨機 IV 使用,這會暴露出相同的明文、以及開頭區塊相同的明文。

例如兩區塊明文 P₁ ‖ P₂ 在 CBC 模式下加密成 C₁ ‖ C₂。若 P₁ ‖ P₂′P₂′ 是與 P₂ 相異的區塊)用相同的 IV 加密,密文會是 C₁ ‖ C₂′——C₂′C₂ 不同,但第一個區塊 C₁ 完全相同

於是攻擊者即使只看得到密文,也能猜出兩段明文的第一個區塊是一樣的。

CBC 模式下解密需要知道加密時用的 IV,因此 IV 會與密文一起以明碼傳送

解密可以平行化#

CBC 的解密可能比加密快得多,原因是平行性:

  • 加密新區塊 Pᵢ 必須等待前一個區塊 C(i−1)
  • 解密一個區塊計算的是 Pᵢ = D(K, Cᵢ) ⊕ C(i−1)不需要前一個明文區塊 P(i−1)

這意味著只要你也知道前一個密文區塊(通常是知道的),所有區塊都能同時平行解密

如何在 CBC 模式加密任意訊息#

回到區塊終結的問題:明文長度不是區塊長度的倍數時該怎麼辦?例如區塊是 16 位元組時,如何用 AES-CBC 加密一段 18 位元組的明文?剩下的兩個位元組怎麼處理?

有兩種廣泛使用的技巧:填充會讓密文比明文稍長,密文竊取則產生與明文等長的密文。

填充#

填充(padding)讓你能加密任意長度的訊息,甚至比單一區塊還短的訊息。區塊密碼的填充規範在 PKCS#7 標準與 RFC 5652 中,幾乎所有用到 CBC 的地方都會用它,例如某些 HTTPS 連線。

16 位元組區塊的填充規則:

剩餘位元組數填充內容
1(明文 1、17、33 位元組…)15 個 0f 位元組(十進位 15)
214 個 0e 位元組(十進位 14)
313 個 0d 位元組(十進位 13)
15(只差一個位元組填滿)1 個 01 位元組
0(明文已是 16 的倍數)16 個 10 位元組(十進位 16)

這個技巧可推廣到最大 255 位元組的任何區塊長度。(區塊再大的話,一個位元組就不夠編碼大於 255 的值了。)

解密帶填充的訊息

  1. 像無填充的 CBC 一樣解密所有區塊。
  2. 確認最後一個區塊的末幾個位元組符合填充規則:以至少一個 01、或至少兩個 02、或至少三個 03 結尾,依此類推。若填充無效(例如末幾個位元組是 01 02 03),訊息就被拒絕;否則解密會剝除填充位元組,回傳剩下的明文位元組。

填充的一個缺點是它讓密文變長——至少一個位元組,至多一個區塊。

密文竊取#

密文竊取(ciphertext stealing)是另一個處理「明文長度不是區塊大小倍數」的技巧。它比填充更複雜、也更不流行,但至少有三個好處:

  • 明文可以是任意位元長度,不限於位元組。例如你可以加密一段 131 位元的訊息。
  • 密文與明文長度完全相同
  • 不受填充預言機攻擊——那是有時能攻破「CBC + 填充」的強力攻擊。

在 CBC 模式下,密文竊取用前一個密文區塊的位元來延伸最後那個不完整的明文區塊,再加密所得的區塊。最後那個不完整的密文區塊,則由前一個密文區塊開頭的位元組成——也就是沒被附加到最後明文區塊的那些位元。

具體來說:P₃ 不完整,它與前一個密文區塊的末幾個位元 XOR,加密結果作為 C₂ 回傳;最後的密文區塊 C₃ 則由前一個密文區塊開頭的位元組成。解密就是這個操作的逆運算。

圖 4-9:CBC 模式加密的密文竊取

密文竊取沒有重大問題,但它不優雅、也難以做對——尤其 NIST 標準(Special Publication 800-38A)規範了三種不同的實作方式

CTR 模式(計數器)#

要避開這些麻煩、又保有密文竊取的好處,你應該使用計數器模式(CTR,counter mode)。

CTR 幾乎稱不上是區塊密碼模式:它把區塊密碼變成一個串流密碼,只是吃位元、吐位元,不必為「區塊」這個概念難堪。

運作方式#

在 CTR 模式中,區塊密碼演算法不變換明文資料,而是加密由一個計數器與一個 nonce 組成的區塊:

  • 計數器(counter)是每個區塊遞增的整數。同一則訊息內不該有兩個區塊使用相同的計數器,但不同訊息可以使用相同的計數器值序列(1, 2, 3, …)。
  • nonce 是只用一次的數字。同一則訊息的所有區塊共用同一個 nonce,但不該有兩則訊息使用相同的 nonce

加密就是把明文與「加密 nonce N 與計數器 Ctr 所得的串流」做 XOR。解密與加密相同,所以你只需要加密演算法就能同時做加密與解密。

#!/usr/bin/env python

from Crypto.Cipher import AES
from Crypto.Util import Counter
from binascii import hexlify as hexa
from os import urandom
from struct import unpack

k = urandom(16)
print "k = %s" % hexa(k)

# pick a starting value for the counter
nonce = unpack('<Q', urandom(8))[0]
# instantiate a counter function
ctr = Counter.new(128, initial_value=nonce)

# pick an instance of AES in CTR mode, using ctr as counter
aes = AES.new(k, AES.MODE_CTR, counter=ctr)

# no need for an entire block with CTR
p = '\x00\x01\x02\x03'

# encrypt p
c = aes.encrypt(p)
print "enc(%s) = %s" % (hexa(p), hexa(c))

# decrypt using the encrypt function
ctr = Counter.new(128, initial_value=nonce)
aes = AES.new(k, AES.MODE_CTR, counter=ctr)
p = aes.encrypt(c)
print "enc(%s) = %s" % (hexa(c), hexa(p))

圖 4-10:CTR 模式

這段程式加密一段 4 位元組的明文得到 4 位元組的密文,再用加密函式解回來:

$ ./aes_ctr.py
k = 130a1aa77fa58335272156421cb2a3ea
enc(00010203) = b23d284e
enc(b23d284e) = 00010203

nonce 的規則#

與 CBC 的初始值一樣,CTR 的 nonce 由加密方提供,並與密文一起以明碼傳送。但與 CBC 的初始值不同的是:CTR 的 nonce 不需要隨機,只需要唯一。

nonce 必須唯一,理由與一次性密碼本不可重複使用相同:呼叫偽隨機串流 S 時,若你用同一個 nonceP₁ 加密成 C₁ = P₁ ⊕ S、把 P₂ 加密成 C₂ = P₂ ⊕ S,那麼 C₁ ⊕ C₂ 就洩漏了 P₁ ⊕ P₂

隨機 nonce 只有在夠長時才管用。 若 nonce 是 n 位元,在大約 2^(n/2) 次加密(以及同樣數量的 nonce)之後,你就有很大機會撞到重複。

因此 64 位元對隨機 nonce 而言不足:大約 2^32 個 nonce 之後就可以預期出現重複,這個數字低到無法接受。

計數器則只要每次新明文都遞增、而且夠長(例如 64 位元計數器),就保證唯一。

CTR 的效能優勢#

CTR 有一個特別的好處:它可以比任何其他模式都快。

它不只可平行化,你甚至可以在還不知道訊息內容之前就開始加密——先挑一個 nonce、算出串流,之後再與明文 XOR 即可。