Go 言語で学ぶ『暗号技術入門』Part 2 -AES-

結城浩さんの『暗号技術入門』を Go 言語を使って学ぶ記事の第2回です。前回は DES と Triple DES について解説しました。

今回は引き続き第3章「対称暗号(共通鍵暗号)」より、新しい標準暗号アルゴリズムである AES (Advanced Encryption Standard) について解説します。

AES (Advanced Encryption Standard)

AES とは

AES (Advanced Encryption Standard) はアメリカ合衆国の標準化機関である NIST (National Institute of Standards and Technology) が2000年に選定した、DES に代わる新たな対称暗号アルゴリズムです。世界中から応募を受け付け、最終的に Rijndael (ラインダール) というアルゴリズムが AES として選ばれました。

現在では、対称暗号を使う場合には AES を用いることが推奨されています。 TLS においても、本文のデータそのものの暗号化には主に AES が使われています。有名な公開鍵暗号は、この対称暗号の鍵を安全に通信相手に送るために使われており、本文のデータを公開鍵暗号で直接暗号化することはありません。

Rijndael とは

Rijndael (ラインダール) はブロック長 128 ビットの対称暗号アルゴリズムです。鍵のビット長は AES の規格では 128, 192, 256 ビットの3種類あり、それぞれ AES-128, AES-192, AES-256 と呼ばれています。

AES を使ってみる

Go で実際に AES による暗号化/復号をおこなってみます。 AES 用のオブジェクトは crypto/aes パッケージの NewCipher() で生成します。戻り値は cipher.Block インターフェースで、この Encrypt()/Decrypt() メソッドはそれぞれ AES による暗号化と復号の処理を実装しています。

http://play.golang.org/p/jUAHbYacE4

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package main

import (
"crypto/aes"
"fmt"
)

func main() {
// 鍵の長さは 16, 24, 32 バイトのどれかにしないとエラー
key := []byte("aes-secret-key-1")
// cipher.Block を実装している AES 暗号化オブジェクトを生成する
c, err := aes.NewCipher(key)
if err != nil {
panic(err)
}

// 暗号化される平文の長さは 16 バイト (128 ビット)
plainText := []byte("secret plain txt")
// 暗号化されたバイト列を格納するスライスを用意する
encrypted := make([]byte, aes.BlockSize)
// AES で暗号化をおこなう
c.Encrypt(encrypted, plainText)
// 結果は暗号化されている
fmt.Println(string(encrypted))
// Output:
// #^ϗ~:f9��˱�1�

// 復号する
decrypted := make([]byte, aes.BlockSize)
c.Decrypt(decrypted, encrypted)
// 結果は元の平文が得られる
fmt.Println(string(decrypted))
// Output:
// secret plain txt
}

AES のアルゴリズム

Rindael も DES と同じく1回の暗号化処理単位を「ラウンド」と呼びます。1ラウンドは以下の図のような4つの処理から成り立っています。

http://flylib.com/books/4/269/1/html/2/images/0130355488/graphics/02fig09.gif

  1. SubBytes: 入力バイトごとに対応表に基いて別のバイトに変換する
  2. ShiftRows: 4バイト単位でまとめた行を左に規則的にシフトして混ぜこぜにする
  3. MixColumns: 4バイトの値をビット演算を用いて別の4バイトに変換する
  4. AddRoundKey: MixColumns の出力 (4バイト) とラウンド鍵 (4バイト) との XOR を取る

以上のラウンドを10〜14回繰り返します。

復号処理はそれぞれの処理の逆処理を、上記手順と逆順でおこないます。すなわち、

  1. AddRoundKey (XOR は逆処理も XOR)
  2. InvMixColumns (MixColumns の逆処理)
  3. InvShiftRows (ShiftRows の逆処理)
  4. InvSubBytes (SubBytes の逆処理)

という手順を暗号化時と同じラウンド回数分おこなうことで復号します。

Go での実装

AES を実装した crypto.Block インターフェースの実装は以下の aesCipher 構造体です。フィールドには暗号化用のラウンド鍵群 enc と復号化用のラウンド鍵群 dec を持っています。これらは AddRoundKey の処理で使われ、1ラウンドで4バイト使うのでスライスの要素は uint32 になっています。

1
2
3
4
5
// A cipher is an instance of AES encryption using a particular key.
type aesCipher struct {
enc []uint32
dec []uint32
}

NewCipher() コンストラクタは以下のようになっています。鍵の長さは 16, 24, 32 バイトのどれかでないとエラーになります。また、 expandKey() で与えられた鍵からラウンド鍵を生成しています。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// NewCipher creates and returns a new cipher.Block.
// The key argument should be the AES key,
// either 16, 24, or 32 bytes to select
// AES-128, AES-192, or AES-256.
func NewCipher(key []byte) (cipher.Block, error) {
k := len(key)
switch k {
default:
return nil, KeySizeError(k)
case 16, 24, 32:
break
}

n := k + 28
c := &aesCipher{make([]uint32, n), make([]uint32, n)}
expandKey(key, c.enc, c.dec)
return c, nil
}

ラウンド鍵を生成する expandKey() の処理を見てみましょう。実は Go における AES の処理は CPU アーキテクチャが AMD64 か否かで分岐しています。 AMD64 の場合には以下のように、アセンブリコードがある場合にはそちらを使用するようになっており、ソースコードには asm_amd64.s というアセンブリも一緒に含まれています。高速に AES の処理をおこなうための工夫でしょう。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// +build amd64

// defined in asm_$GOARCH.s
func hasAsm() bool
func expandKeyAsm(nr int, key *byte, enc *uint32, dec *uint32)

var useAsm = hasAsm()

func expandKey(key []byte, enc, dec []uint32) {
if useAsm {
rounds := 10
switch len(key) {
case 128 / 8:
rounds = 10
case 192 / 8:
rounds = 12
case 256 / 8:
rounds = 14
}
expandKeyAsm(rounds, &key[0], &enc[0], &dec[0])
} else {
expandKeyGo(key, enc, dec)
}
}

先に「ラウンドの回数は10〜14回」と書きましたが、上記を見ると、鍵長が 128, 192, 256 ビットだとそれぞれ 10, 12, 14 回のラウンドをおこなうことがわかります。

一方 AMD64 以外の CPU アーキテクチャの場合には、以下のように通常の Go で記述された処理をおこないます。

1
2
3
4
5
// +build !amd64

func expandKey(key []byte, enc, dec []uint32) {
expandKeyGo(key, enc, dec)
}

また、暗号化/復号をおこなう Encrypt()/Decrypt() メソッドの実装は以下のようになっています。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func (c *aesCipher) Encrypt(dst, src []byte) {
if len(src) < BlockSize {
panic("crypto/aes: input not full block")
}
if len(dst) < BlockSize {
panic("crypto/aes: output not full block")
}
encryptBlock(c.enc, dst, src)
}

func (c *aesCipher) Decrypt(dst, src []byte) {
if len(src) < BlockSize {
panic("crypto/aes: input not full block")
}
if len(dst) < BlockSize {
panic("crypto/aes: output not full block")
}
decryptBlock(c.dec, dst, src)
}

encryptBlock(), decryptBlock()expandKey() と同様に、 AMD 64 の場合はアセンブリコードを、そうでなければ Go のコードを使用するようになっています。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// +build amd64

// defined in asm_$GOARCH.s
func hasAsm() bool
func encryptBlockAsm(nr int, xk *uint32, dst, src *byte)
func decryptBlockAsm(nr int, xk *uint32, dst, src *byte)

var useAsm = hasAsm()

func encryptBlock(xk []uint32, dst, src []byte) {
if useAsm {
encryptBlockAsm(len(xk)/4-1, &xk[0], &dst[0], &src[0])
} else {
encryptBlockGo(xk, dst, src)
}
}

func decryptBlock(xk []uint32, dst, src []byte) {
if useAsm {
decryptBlockAsm(len(xk)/4-1, &xk[0], &dst[0], &src[0])
} else {
decryptBlockGo(xk, dst, src)
}
}

AMD64 以外はそのまま Go のコードを呼びます。

1
2
3
4
5
6
7
8
9
// +build !amd64

func encryptBlock(xk []uint32, dst, src []byte) {
encryptBlockGo(xk, dst, src)
}

func decryptBlock(xk []uint32, dst, src []byte) {
decryptBlockGo(xk, dst, src)
}

encryptBlockGo(), decryptBlockGo() の実装の詳細はここでは割愛します。

ブロック暗号の「モード」

これまで見てきた対称暗号はすべて「固定長」の平文(ブロック)を暗号化するアルゴリズムでした。 AES の場合なら1回の処理で暗号化するのは 128 ビット (16 バイト) です。

しかし僕たちが普段暗号化したい文章は当然もっと長いので、このブロック暗号を繰り返し使って、長い文章全体を暗号化する必要があります。ブロック暗号を繰り返す方法のことをブロック暗号の「モード」と呼びます。ブロック暗号のモードにはいろいろな種類があります。

ということで次回は第4章「ブロック暗号のモード」CBC モードCTR モードについて紹介したいと思います。