Skip to content

対称暗号とモード

実装: 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
モードの違い。ECB は同じ平文ブロックが同じ暗号文に。CBC は前の暗号文を混ぜ、CTR は鍵で作った擬似乱数列と XOR する

順に見ていく。

  1. ブロック暗号は Feistel で作る: 左右に分け片側だけ混ぜるのを繰り返す。混ぜる関数が可逆でなくても全体は必ず戻せる
  2. モードで安全性が決まる: ECB は模様を漏らす。CBC / CTR は前のブロックや擬似乱数列を混ぜて、同じ平文でも別の暗号文にする
  3. 暗号化だけでは足りない: CTR はビット反転で中身を書き換えられる。改ざんを防ぐには認証(MAC)を足す

① Feistel 暗号: 可逆でない関数で可逆な暗号を作る

ブロック暗号を作る。Feistel 構造の妙は、ブロックを左右に分けて片側だけを鍵で混ぜること。混ぜる関数 F がどんなに複雑でも(逆算できなくても)、この構造なら全体は必ず元に戻せる:

go

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 する:

go

// 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 なので、暗号文のあるビットを反転させると、復号後の平文の同じビットが反転する。攻撃者は鍵を知らなくても、狙った位置を書き換えられる:

go

// 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 がその改ざんを検知する様子だ。

デモ対称暗号とモード同一ブロック=同一暗号文
ECBの模様漏れCTRの可鍛性Seal/Open(防御)
平文: "SECRET__" × 3
ECB51954e2bfd3639e651954e2bfd3639e651954e2bfd3639e6
CBC432bc5fa280e2b1a20197ae6af408fd4f07b8297f9786eae
同じ色 = 同じ暗号文ブロック。ECB は 3 つとも同色 = 模様が漏れている

同じ平文ブロック 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 を使う

参考資料