対称暗号とモード
実装:
crypto/cipher// 実行:go test ./crypto/cipher/
ブロック暗号は固定長のブロックを鍵で可逆に混ぜる関数だ。だが長いデータはブロックに切って繋ぐ必要があり、繋ぎ方(モード)で安全性が決まる。ECBは同じ平文が同じ暗号文になり模様が漏れる。CBCは前のブロックを混ぜ、CTRは擬似乱数列とXORする。暗号化だけでは改ざんを防げず、CTRはビット反転で中身を書き換えられる。Feistel暗号と各モードを自作し、認証を足したAEADが要ることを確かめる。
この章で作るもの
暗号化には2つの系統がある。1つは同じ鍵で暗号化も復号もする対称暗号(共通鍵暗号)で、速い代わりに、その鍵をどうやって相手に渡すかという問題が残る。もう1つが、渡してよい鍵と手元に隠す鍵を分ける公開鍵暗号で、鍵の配布は解けるが計算が重く、大きなデータには向かない(後の暗号(RSA)の章で作る)。実際の通信では両方を使い、公開鍵暗号で短い共通鍵を配り、その共通鍵で本文を高速に暗号化する。この章は前者、本文を高速に暗号化する側になる。
対称暗号の中心はブロック暗号で、固定長のブロック 1 つを鍵で可逆に混ぜる。だが本題はその使い方にある。本文はブロックより長いので、切って 1 つずつ暗号化する。このとき各ブロックをどう繋ぐかを暗号利用モードと呼ぶ。素朴に独立して暗号化するモード(ECB)は、同じ平文ブロックが同じ暗号文ブロックになるので、繰り返しの模様が暗号文に透ける。しかも、暗号化しただけでは改ざんを防げない。この章では小さな Feistel 暗号を作り、ECB の穴、CBC / CTR、そして認証を足した AEAD までを組む。
ECB P0→[E]→C0 P1→[E]→C1 同じ P なら同じ C(模様が漏れる)
CBC P0⊕IV→[E]→C0 P1⊕C0→[E]→C1 前の暗号文を混ぜる
CTR [E](nonce+0)=K0 → C0=P0⊕K0 鍵で乱数列を作り XOR
[E](nonce+1)=K1 → C1=P1⊕K1順に見ていく。
- ブロック暗号は Feistel で作る: 左右に分け片側だけ混ぜるのを繰り返す。混ぜる関数が可逆でなくても全体は必ず戻せる
- モードで安全性が決まる: ECB は模様を漏らす。CBC / CTR は前のブロックや擬似乱数列を混ぜて、同じ平文でも別の暗号文にする
- 暗号化だけでは足りない: CTR はビット反転で中身を書き換えられる。改ざんを防ぐには認証(MAC)を足す
① Feistel 暗号: 可逆でない関数で可逆な暗号を作る
ブロック暗号を作る。Feistel 構造の妙は、ブロックを左右に分けて片側だけを鍵で混ぜること。混ぜる関数 F がどんなに複雑でも(逆算できなくても)、この構造なら全体は必ず元に戻せる:
const (
// BlockSize はブロック暗号が一度に処理するバイト数。
BlockSize = 8
// rounds は Feistel の段数。多いほど混ざる。
rounds = 16
)
// Cipher は 8 バイトブロックの Feistel 暗号。
type Cipher struct{ rk []uint32 }
// NewCipher は鍵から段ごとの鍵(ラウンド鍵)を導いて暗号を作る。
func NewCipher(key []byte) *Cipher { return &Cipher{rk: expandKey(key)} }
// expandKey は鍵を畳んで種を作り、そこから rounds 個のラウンド鍵を生成する。
func expandKey(key []byte) []uint32 {
var seed uint64 = 1469598103934665603 // FNV offset
for _, b := range key {
seed = (seed ^ uint64(b)) * 1099511628211
}
rk := make([]uint32, rounds)
for i := range rk {
seed = seed*6364136223846793005 + 1442695040888963407
rk[i] = uint32(seed >> 32)
}
return rk
}
// feistelF は片側 32bit を鍵で混ぜる関数(可逆でなくてよいのが Feistel の妙)。
func feistelF(r, k uint32) uint32 {
x := (r ^ k) * 2654435761
x = bits.RotateLeft32(x+0x9e3779b9, 7)
x ^= bits.RotateLeft32(x, 11)
return x
}
// encryptBlock は 1 ブロックを暗号化する。
// 各段で (L,R) → (R, L ⊕ F(R,k))。F が可逆でなくても全体は必ず戻せる。
func (c *Cipher) encryptBlock(dst, src []byte) {
l := binary.LittleEndian.Uint32(src[0:])
r := binary.LittleEndian.Uint32(src[4:])
for i := 0; i < rounds; i++ {
l, r = r, l^feistelF(r, c.rk[i])
}
binary.LittleEndian.PutUint32(dst[0:], l)
binary.LittleEndian.PutUint32(dst[4:], r)
}
// decryptBlock は暗号化の逆。ラウンド鍵を逆順に辿る。
func (c *Cipher) decryptBlock(dst, src []byte) {
l := binary.LittleEndian.Uint32(src[0:])
r := binary.LittleEndian.Uint32(src[4:])
for i := rounds - 1; i >= 0; i-- {
l, r = r^feistelF(l, c.rk[i]), l
}
binary.LittleEndian.PutUint32(dst[0:], l)
binary.LittleEndian.PutUint32(dst[4:], r)
}各段で (L, R) → (R, L ⊕ F(R, k)) と変換する。復号は同じ段を逆順に辿るだけだ。F を逆算する必要がないので、F は一方向でよい。だからこそ F を思い切り混ぜられる。テストで 1 ブロックの暗号化・復号が元に戻ること、暗号文が平文と違うことを固定した。DES もこの骨格で、AES は別構造(代入・置換)だが、ブロックを鍵で可逆に混ぜるという役割は同じだ。
② モード: ブロックをどう繋ぐか
ここからが本題だ。まず最も素朴な ECB。各ブロックを独立に暗号化する。これには致命的な穴がある。同じ平文ブロックは、常に同じ暗号文ブロックになる。CBC は暗号化の前に直前の暗号文ブロックと XOR することで、これを断つ。CTR は鍵で擬似乱数列を作り、平文と XOR する:
// pkcs7Pad は末尾を BlockSize の倍数に詰める。詰めた分の値をそのバイトに書く
// (例: 3 バイト詰めるなら 03 03 03)。ちょうど倍数でも 1 ブロック足す。
func pkcs7Pad(data []byte) []byte {
n := BlockSize - len(data)%BlockSize
out := make([]byte, len(data)+n)
copy(out, data)
for i := len(data); i < len(out); i++ {
out[i] = byte(n)
}
return out
}
// pkcs7Unpad は詰め物を外す。壊れていれば false。
func pkcs7Unpad(data []byte) ([]byte, bool) {
if len(data) == 0 || len(data)%BlockSize != 0 {
return nil, false
}
n := int(data[len(data)-1])
if n < 1 || n > BlockSize {
return nil, false
}
for i := len(data) - n; i < len(data); i++ {
if data[i] != byte(n) {
return nil, false
}
}
return data[:len(data)-n], true
}
// EncryptECB は各ブロックを独立に暗号化する(最も素朴。模様が漏れる)。
func (c *Cipher) EncryptECB(plain []byte) []byte {
p := pkcs7Pad(plain)
out := make([]byte, len(p))
for i := 0; i < len(p); i += BlockSize {
c.encryptBlock(out[i:i+BlockSize], p[i:i+BlockSize])
}
return out
}
// DecryptECB は EncryptECB の逆。
func (c *Cipher) DecryptECB(ct []byte) ([]byte, bool) {
if len(ct)%BlockSize != 0 {
return nil, false
}
out := make([]byte, len(ct))
for i := 0; i < len(ct); i += BlockSize {
c.decryptBlock(out[i:i+BlockSize], ct[i:i+BlockSize])
}
return pkcs7Unpad(out)
}
// EncryptCBC は各ブロックを暗号化する前に、直前の暗号文ブロックと XOR する。
// 先頭は初期化ベクトル(iv)と XOR。同じ平文でも iv が違えば別の暗号文になる。
func (c *Cipher) EncryptCBC(plain, iv []byte) []byte {
p := pkcs7Pad(plain)
out := make([]byte, len(p))
prev := make([]byte, BlockSize)
copy(prev, iv)
for i := 0; i < len(p); i += BlockSize {
block := make([]byte, BlockSize)
for j := 0; j < BlockSize; j++ {
block[j] = p[i+j] ^ prev[j] // 直前の暗号文を混ぜる
}
c.encryptBlock(out[i:i+BlockSize], block)
prev = out[i : i+BlockSize]
}
return out
}
// DecryptCBC は EncryptCBC の逆。
func (c *Cipher) DecryptCBC(ct, iv []byte) ([]byte, bool) {
if len(ct)%BlockSize != 0 || len(ct) == 0 {
return nil, false
}
out := make([]byte, len(ct))
prev := make([]byte, BlockSize)
copy(prev, iv)
for i := 0; i < len(ct); i += BlockSize {
dec := make([]byte, BlockSize)
c.decryptBlock(dec, ct[i:i+BlockSize])
for j := 0; j < BlockSize; j++ {
out[i+j] = dec[j] ^ prev[j]
}
prev = ct[i : i+BlockSize]
}
return pkcs7Unpad(out)
}
// keystreamBlock は nonce と counter を暗号化して擬似乱数ブロックを作る。
func (c *Cipher) keystreamBlock(nonce []byte, counter uint32) []byte {
in := make([]byte, BlockSize)
copy(in, nonce) // 前半は nonce
binary.LittleEndian.PutUint32(in[4:], counter)
out := make([]byte, BlockSize)
c.encryptBlock(out, in)
return out
}
// CTR は平文を「鍵で作った擬似乱数列」と XOR するだけ(ストリーム暗号化)。
// 暗号化と復号が同じ操作になる。詰め物が要らず、長さもそのまま。
func (c *Cipher) CTR(data, nonce []byte) []byte {
out := make([]byte, len(data))
var counter uint32
for i := 0; i < len(data); i += BlockSize {
ks := c.keystreamBlock(nonce, counter)
for j := 0; j < BlockSize && i+j < len(data); j++ {
out[i+j] = data[i+j] ^ ks[j]
}
counter++
}
return out
}ECB の穴は、画像を暗号化すると目に見える。有名な「ECB ペンギン」は、ペンギンの画像を ECB で暗号化しても輪郭がそのまま残る。同じ色の並び(同じ平文ブロック)が同じ暗号文ブロックになるからだ。テストで、同じ 8 バイトを 3 回繰り返した平文が、ECB では暗号文も同じ 3 ブロックになり、CBC ではばらけることを固定した。CBC は初期化ベクトル(IV)を毎回変えることで、同じ平文でも毎回違う暗号文にする。CTR は暗号化と復号が同じ操作になり(どちらも擬似乱数列との XOR)、詰め物も要らず長さもそのままという扱いやすさがある。
③ 暗号化だけでは足りない: 認証を足す
CTR には落とし穴がある。暗号文は平文とキーストリームの XOR なので、暗号文のあるビットを反転させると、復号後の平文の同じビットが反転する。攻撃者は鍵を知らなくても、狙った位置を書き換えられる:
// Seal は CTR で暗号化してから、nonce と暗号文に HMAC を付ける(encrypt-then-MAC)。
// 返すのは nonce ‖ 暗号文 ‖ tag。これで秘匿だけでなく改ざん検知もできる(AEAD 相当)。
func (c *Cipher) Seal(key, plain, nonce []byte) []byte {
ct := c.CTR(plain, nonce)
out := append(append([]byte{}, nonce...), ct...)
tag := hash.HMAC(key, out)
return append(out, tag[:]...)
}
// Open は tag を先に検証し、正しいときだけ復号する。改ざんは復号前に弾く。
func (c *Cipher) Open(key, sealed, nonce []byte) ([]byte, bool) {
if len(sealed) < len(nonce)+hash.Size {
return nil, false
}
body := sealed[:len(sealed)-hash.Size]
var got [hash.Size]byte
copy(got[:], sealed[len(sealed)-hash.Size:])
want := hash.HMAC(key, body)
if !hash.Equal(got, want) {
return nil, false // 改ざんされている。復号すらしない
}
ct := body[len(nonce):]
return c.CTR(ct, nonce), true
}テストでこれを実演した。balance=0000100(送金額 100)を CTR で暗号化した暗号文に対し、攻撃者は該当バイトに '1'⊕'9' を XOR するだけで、復号結果を balance=0000900(送金額 900)に書き換えられる。復号は何事もなく成功する。暗号化は「読めなくする」だけで、「書き換えられなくする」ことは保証しない。この 2 つは別の性質だ。
対策が Seal / Open だ。CTR で暗号化した後、nonce と暗号文全体に HMAC を付ける(encrypt-then-MAC)。復号する側は、まず tag を検証し、正しいときだけ復号する。改ざんされていれば tag が合わず、復号する前に弾く。テストで、同じ改ざんが Seal / Open では tag 検証に引っかかって拒否されることを固定した。秘匿(読めなくする)と認証(改ざんを防ぐ)をひとつにまとめたこの仕組みを AEAD と呼び、実物は GCM や ChaCha20-Poly1305 がこれを担う。
動かす
下のデモは 3 つを見せる。ECB は繰り返し模様が暗号文に透ける様子、CTR は鍵なしで中身を書き換えられる可鍛性、そして Seal / Open がその改ざんを検知する様子だ。
同じ平文ブロック SECRET__ を 3 回並べた。ECB では暗号文も同じ 3 ブロック(同色)になり、繰り返し模様が透ける。CBC は前の暗号文を混ぜるので全ブロックがばらける
Go 実装をそのまま移植して計算している。ECB は同じ平文ブロックを同じ暗号文にするので模様が漏れる。 CTR は平文と擬似乱数列の XOR なので、暗号文のビットを反転すると平文の同じ位置が反転する(可鍛性)。 暗号化は「読めなくする」だけで「書き換えを防ぐ」ものではない。認証(HMAC)を足した AEAD で初めて改ざんを弾ける。
設計の観点
- ECB は使わない: 用途を問わず ECB は避ける。模様が漏れ、ブロックの入れ替えも効く。既定は AEAD(GCM 等)
- IV / nonce の扱い: CBC の IV は予測不能に、CTR の nonce は同じ鍵で二度使わない。nonce 再利用はキーストリームが同じになり、XOR で平文が漏れる致命傷
- 暗号化と認証は必ずセット: 生の暗号化(CTR/CBC 単体)は改ざん可能。encrypt-then-MAC か AEAD で認証を足す。認証なしの暗号は「読めないが書き換え放題」
- encrypt-then-MAC の順序: 暗号文に MAC を付ける(mac-then-encrypt や encrypt-and-mac でない)。復号前に認証でき、パディングオラクル攻撃も避けられる
- 対称と公開鍵の使い分け: 公開鍵で共通鍵を配り、対称暗号で本文を暗号化するハイブリッドが定石。対称は速く、公開鍵は鍵配布を解く
対照と実例
| モード | 模様漏れ | 並列化 | 詰め物 | 認証 | 評価 |
|---|---|---|---|---|---|
| ECB | 漏れる | 可 | 要 | なし | 使わない |
| CBC | なし | 復号のみ可 | 要 | なし | 単体は不足(要MAC) |
| CTR | なし | 可 | 不要 | なし | 単体は不足(要MAC) |
| GCM(CTR+認証) | なし | 可 | 不要 | あり | 推奨(AEAD) |
裏どり:
- ECB ペンギン: ECB で画像を暗号化すると輪郭が残る有名な例。モードの重要性を一目で示す
- AES-GCM / ChaCha20-Poly1305: 現代の標準 AEAD。TLS 1.3 はこの 2 つに絞った。暗号化と認証を一体で提供
- Cryptographic Doom Principle (Moxie Marlinspike): 「復号してから検証する設計は破れる」。encrypt-then-MAC が正しい順序である根拠
- DES / AES: DES は Feistel、AES は代入置換ネットワーク。ブロック暗号の 2 大構造
簡略化したこと
- 自作の弱い暗号: 8 バイトブロック・16 段の玩具。実物は AES(128bit ブロック・S-box)。安全性は保証しない
- nonce/IV は手で与える: 実運用は安全な乱数生成と使い回し防止が必須
- AEAD は encrypt-then-MAC: 本物の GCM は GHASH による認証。ここは HMAC で概念を示す
- パディングオラクルは概念のみ: CBC の詰め物検証を突く攻撃は扱わない。だから AEAD を使う
参考資料
- Cryptographic Right Answers (Latacora) — モードと AEAD の実務的な指針
- The Cryptographic Doom Principle — encrypt-then-MAC の順序が正しい理由
- NIST SP 800-38A/D — 暗号利用モードと GCM の標準
- 実装: crypto/cipher