ちりもつもればミルキーウェイ

好奇心に可処分時間が奪われる

Goでブロック暗号のECB暗号モード書いた

はじめに

暗号周りがなんとなく気になって、一番簡単そうな暗号利用モードのECB実装するかーという気持ちになったのでやった

色々調べたら ちょうどGoには実装されてないっぽく 、かつ単にブロックを順番に暗号化するだけでクソ単純でとっつきやすそうなのでやってみた

いちおう明示しておくとECBは非推奨だから暗号モードを選ぶときはコントロール不能な理由がない限り使わないほうが良いです
あくまで学習の目的で書いただけで、現実世界でブロック暗号使うときはCBCとかを使おうね!

全体のコード

はじめに実行可能な全体のコードをおいておく さっきのリンク先issueのコメントにもあるけど、単にブロック順に暗号化してつめればよい。

コード

package main

import (
    "bytes"
    "crypto/aes"
    "encoding/hex"
    "fmt"
)

func encrypt(key []byte, plaintext []byte) ([]byte, error) {
    block, err := aes.NewCipher(key)
    if err != nil {
        return nil, err
    }

    bs := block.BlockSize()
    padded := pkcs7Padding(plaintext, bs)
    ciphertext := make([]byte, len(padded))
    for len(padded) > 0 {
        block.Encrypt(ciphertext[len(ciphertext)-len(padded):], padded)
        padded = padded[bs:]
    }
    return ciphertext, nil
}

func decrypt(key []byte, ciphertext []byte) ([]byte, error) {
    block, err := aes.NewCipher(key)
    if err != nil {
        return nil, err
    }

    bs := block.BlockSize()
    padded := make([]byte, len(ciphertext))
    for len(ciphertext) > 0 {
        block.Decrypt(padded[len(padded)-len(ciphertext):], ciphertext)
        ciphertext = ciphertext[bs:]
    }
    plaintext := pkcs7UnPadding(padded)
    return plaintext, nil
}

func pkcs7Padding(src []byte, blockSize int) []byte {
    padSize := blockSize - len(src)%blockSize
    paddingBytes := bytes.Repeat([]byte{byte(padSize)}, padSize)
    return append(src, paddingBytes...)
}

func pkcs7UnPadding(src []byte) []byte {
    srcSize := len(src)
    padSize := int(src[srcSize-1])
    return src[:(srcSize - padSize)]
}

func main() {
    key, _ := hex.DecodeString("1234567890ABCDEF1234567890ABCDEF")
    pt := []byte("this is plain text")

    ct, _ := encrypt(key, pt)
    fmt.Println(hex.EncodeToString(ct))

    pt, _ = decrypt(key, ct)
    fmt.Println(string(pt))
}

playground はこれ

出力は

f6546d6dea7d9a554da4958d919a790053c9f1f20b1f18bae55837b86b649b52
this is plain text

となる

PKCS#7 Padding

まずはじめに、ECBは入力のバイト数がブロック長の倍数になってる必要があるため、パディングをつくらねばならない

そこで親の顔より見飽きた PKCS#7 Padding の出番だ!

(余談だけど、いってることはほぼ PKCS#5 Padding と同じ。paddingのとり方に着目すると、PKCS#5はブロック数が8byte前提の仕様なのがPKCS#7は256byteまで可変ブロックサイズとれるぞ、というふうに拡張されてるだけ。PKCS#7 は完全に PKCS#5 の仕様を内包しているので、言語によっては歴史的経緯でこのあたりの処理の名前がカオスになってて嘘つきになってる場合がある 1 。観測範囲だと、javaかなにかのPKCS#5関数は実態はPKCS#7仕様にのっとった処理になってるっぽい)

ようするに k - (l mod k) 。実装は以下

unc pkcs7Padding(src []byte, blockSize int) []byte {
    padSize := blockSize - len(src)%blockSize
    paddingBytes := bytes.Repeat([]byte{byte(padSize)}, padSize)
    return append(src, paddingBytes...)
}

PKCS#7 Paddingは、単に

  • ブロックサイズで割ったあまりのバイト数でブロックサイズの倍数になるようにバイト埋める
  • あまりがなかったら(最初からちょうどブロックサイズの倍数だったら)ブロックサイズぶんバイトを埋める

だけ

ブロックサイズからはみ出したやつをブロックサイズぴったりにするには何byte必要?というのをひたすら詰めていく
たとえばブロックサイズ16のAESを使ってるなら、

  • 10byteをわたされたとき: [06,06,06,06,06,06]
  • 16byteをわたされたとき: [0F, 0F, 0F, 0F, 0F, 0F, 0F, 0F, 0F, 0F, 0F, 0F, 0F, 0F, 0F, 0F]

というbyteで詰めればよい

なんであまりが余りがないときにブロックサイズ分つめるの?最初からブロックサイズの倍数なんだから何もしないでいいじゃん!
というのも声がきこえそうだけど、多分unpadding処理を以下のように書きたいからだと思う
(以下のコードは説明のためにちょっと過剰に変数化してる。実装するときはこのくらいなら変数にしなくても良いと思う)

func pkcs7UnPadding(src []byte) []byte {
    srcSize := len(src)
    padSize := int(src[srcSize-1])
    return src[:(srcSize - padSize)]
}

このように、終端のbyteの数だけsrcから落とせば良い!というルールで簡単にunpaddingしたいからブロックサイズちょうど分のときはブロックサイズまるごとpaddingつめるのだとおもわれ

暗号/復号部分

ここが主題なんだけど、ほぼ見たとおりで説明することがなくて悲しい
CBCなどと違って前ブロックの内容が処理に絡むこともなく、単にブロックごとに暗号化したり復号したりするだけ

一応やってることを説明すると、暗号化部分は

func encrypt(key []byte, plaintext []byte) ([]byte, error) {
    block, err := aes.NewCipher(key)
    if err != nil {
        return nil, err
    }

    bs := block.BlockSize()
    padded := pkcs7Padding(plaintext, bs)
    ciphertext := make([]byte, len(padded))
    for len(padded) > 0 {
        block.Encrypt(ciphertext[len(ciphertext)-len(padded):], padded)
        padded = padded[bs:]
    }
    return ciphertext, nil
}

でやってて、ここでは

  1. PKCS#7 Paddingでpaddingとる
  2. 暗号対象のpaddingとったバイト列が空になるまでブロックごとに暗号化して結果をいれるバイト列に突っ込んでいく。どこまで暗号化が完了してるかは、最初のbyte長 - 暗号化終わった部分が消えたbyte長 で判断

という感じ。

復号部分は

func decrypt(key []byte, ciphertext []byte) ([]byte, error) {
    block, err := aes.NewCipher(key)
    if err != nil {
        return nil, err
    }

    bs := block.BlockSize()
    padded := make([]byte, len(ciphertext))
    for len(ciphertext) > 0 {
        block.Decrypt(padded[len(padded)-len(ciphertext):], ciphertext)
        ciphertext = ciphertext[bs:]
    }
    plaintext := pkcs7UnPadding(padded)
    return plaintext, nil
}

となっていて、

  1. 暗号対象のpaddingとったバイト列が空になるまでブロックごとに暗号化して結果をいれるバイト列に突っ込んでいく。どこまで暗号化が完了してるかは、最初のbyte長 - 暗号化終わった部分が消えたbyte長 で判断
  2. PKCS#7 Padding が入れられてる前提でunpaddingする

暗号と逆の手順なのでこうですね。あんまり説明することがない

opensslとの出力比較

opensslは自動生成機能が優秀で、nosaltとか指定しないと内部でかってにいい感じにsalt振ったりして結果が一致しなくなっちゃうので、こっちで色々指定する

$ printf 'this is plain text' | openssl aes-128-ecb -e -nosalt -K '1234567890ABCDEF1234567890ABCDEF' | hexdump -v -e '/1 "%02X"'
F6546D6DEA7D9A554DA4958D919A790053C9F1F20B1F18BAE55837B86B649B52
  • goの出力は f6546d6dea7d9a554da4958d919a790053c9f1f20b1f18bae55837b86b649b52
  • opensslの出力は F6546D6DEA7D9A554DA4958D919A790053C9F1F20B1F18BAE55837B86B649B52

で完全に同一だ!やったね!
(テストも書いたけどplaygroundでテスト書こうとするとmain関数消す必要あったり面倒そうなのでリンクとかははらない)

おわりに

本題のECBモードについて特段語ることがなくて悲しかった

ECBはめちゃくちゃ単純だけどあんまり安全ではないとされているので勉強目的以外は使わないようにしようね


  1. もっと余談だけど、このあたりを把握してない外部連携先から「暗号アルゴリズムはAESです!paddingはPKCS#5 Paddingでうめてください!」のように言われる場合がたまにあり、忖度力を発揮しなければいけないときがある(AESはブロック長が16byteなので、ほんとにPKCS#5 Paddingで埋めると8の倍数になるだけで16の倍数にはならないので意味ない。つまりこの場合はベンダ側の利用している言語なりがPKCS#5といいつつ実際はPKCS#7をつかっているのかも…?という可能性を頭に入れながらコミュニケーション取る必要がある)