Go で DB にいれるデータを暗号化したい

センシティブなデータを DB に保存するにあたって、Go で暗号化を実装した。 その過程で暗号化についていろいろ調べたので、Go で暗号化したいけど暗号化のことはよくわからないという人を対象にまとめる。

今回は鍵の導出に PBKDF2 を、暗号アルゴリズムに AES-256-GCM を採用したのでこれをベースに説明していく1

PBKDF2

前提として、暗号化には鍵と呼ばれるデータが必要になる。 暗号文 (暗号化の結果) は、暗号アルゴリズムと平文、そして鍵の組み合わせで決定される。 また、鍵は復号にも用いられるため第三者に知られてはならない。

この鍵に human-readable な文字列を使うと辞書攻撃や総当たり攻撃に弱いという問題がある。 対策として、文字列のハッシュ化を繰り返し行うストレッチングと呼ばれる処理をすることで攻撃を困難にする。

PBKDF2 は任意のハッシュ関数に基づいて上記を行うアルゴリズムで、これによる鍵の導出の実装はこうなる。

package main

import (
    "crypto/sha256"

    "golang.org/x/crypto/pbkdf2"
)

func main() {
    password := []byte("password")
    salt := []byte("salt")

    // func pbkdf2.Key(password []byte, salt []byte, iter int, keyLen int, h func() hash.Hash) []byte
    key := pbkdf2.Key(password, salt, 1000, 32, sha256.New)
}

今回はベースとなるハッシュ関数 h を SHA-256、ストレッチング回数 iter を 1000 とした2。 鍵長 keyLen を 32 bytes にしていること、また salt については後述する。

AES-256-GCM

次に AES-256-GCM について。このままだと難しいので AES と 256 と GCM に分解する。

AES

共通鍵暗号のブロック暗号アルゴリズム。このままだと難しいので共通鍵暗号とブロック暗号に分解する。

共通鍵暗号3

暗号化と復号に同一の鍵を用いる暗号方式のこと。 暗号化に用いた鍵があればデータを復号できる。

ブロック暗号4

固定長のデータ (ブロックと呼ばれる) を単位に暗号化する暗号のこと。

256

AES で暗号化する際に用いる鍵のサイズ (ビット数)。 256 の他に 128 と 192 がある。 長ければ長いほど計算量が増えて安全になる。

GCM

ブロック暗号では固定長のデータしか扱えないため、ブロック長より長いデータを暗号化するためにはブロック暗号を繰り返して適用する必要がある。 この繰り返しの方法を暗号利用モードといって、GCM はそのひとつ。

ここで、AES-256-GCM を扱う実装についてまとめる。

package main

import (
    "crypto/aes"
    "crypto/cipher"
)

func main() {
    var key [32]byte

    // 鍵として 256 bits (= 32 bytes) のバイトスライスを引数に渡すと AES-256 が選択される
    block, err := aes.NewCipher(key[:])
    if err != nil {
        panic(err)
    }

    aead, err := cipher.NewGCM(block)
    if err != nil {
        panic(err)
    }
}

コメントにも書いたが aes.NewCipher() に 32 bytes の鍵を渡すことで AES-256 が選択される。 PBKDF2 で導出する鍵長を 32 bytes にしていたのはこのためだ。

また cipher.NewGCM() の戻り値に aead と名前をつけているが、次はこれについて説明する。

AEAD

暗号化に加えて、認証を同時に行う暗号利用モードを AEAD という。 認証によって、暗号文が改ざんされていないこと、また正規に生成されたものであることが保証される5

GCM は認証付きの暗号利用モードのため、cipher.NewGCM()cipher.AEAD を返す。 この cipher.AEAD を用いた暗号化の実装は以下になる。

package main

import (
    "crypto/cipher"
    "crypto/rand"
    "io"
)

func main() {
    var aead cipher.AEAD

    plaintext := []byte("plaintext")

    iv := make([]byte, aead.NonceSize())
    if _, err := io.ReadFull(rand.Reader, iv); err != nil {
        panic(err)
    }

    encryptedData := aead.Seal(iv, iv, plaintext, nil)
}

今度は iv という名前がいっぱい出てきた。

IV

暗号文は暗号アルゴリズムと平文と鍵の組み合わせで決定されると前述した。 これは組み合わせが一致していれば暗号文は常に同じになるということであり、暗号初期化ベクトルは役に立つのか?|徳丸 浩|note で紹介されているような危険性がある。 そこで、IV (Initial Vector) と呼ばれる一意な値を付加することで6、組み合わせが一致していても異なる暗号文が出力されるようにする7

復号には暗号化のときに付加した IV が必要になるのだが、それを踏まえて先ほどのコードの

   encryptedData := aead.Seal(iv, iv, plaintext, nil)

の部分を詳しくみていく。まずは Seal()シグネチャを以下に示す8

// nonce はここでは IV を指す。IV は一意である必要があるため、使い捨てとなる。このような値を nonce (number used once) と呼ぶ。
// 戻り値は dst に暗号文を append したバイトスライスになる。
func (cipher.AEAD).Seal(dst []byte, nonce []byte, plaintext []byte, additionalData []byte) []byte

ここで引数 dstiv を渡すことによって、戻り値が IV と暗号文を連結したバイトスライスになる。 IV のサイズは aead.NonceSize() で取得可能なため、このバイトスライスを暗号データとして保存しておくことで復号時に以下で IV を取得できる。

package main

func main() {
    var encryptedData []byte

    iv, ciphertext := encryptedData[:aead.NonceSize()], encryptedData[aead.NonceSize():]
}

また、この IV と暗号文をもとに復号するコードは以下になる。

package main

import "crypto/cipher"

func main() {
    var aead cipher.AEAD
    var iv, ciphertext []byte

    plaintext, err := aead.Open(nil, iv, ciphertext, nil)
    if err != nil {
        panic(err)
    }
}

まとめ

PBKDF2 による鍵の導出と AES-256-GCM による暗号化を Go で実装した。

package main

import (
    "crypto/aes"
    "crypto/cipher"
    "crypto/rand"
    "crypto/sha256"
    "fmt"
    "io"

    "golang.org/x/crypto/pbkdf2"
)

func main() {
    password := []byte("password")
    salt := []byte("salt")

    key := pbkdf2.Key(password, salt, 1000, 32, sha256.New)

    block, err := aes.NewCipher(key)
    if err != nil {
        panic(err)
    }

    aead, err := cipher.NewGCM(block)
    if err != nil {
        panic(err)
    }

    plaintext := []byte("plaintext")

    iv := make([]byte, aead.NonceSize())
    if _, err := io.ReadFull(rand.Reader, iv); err != nil {
        panic(err)
    }

    // encrypt
    encryptedData := aead.Seal(iv, iv, plaintext, nil)

    // decrypt
    iv2, ciphertext := encryptedData[:aead.NonceSize()], encryptedData[aead.NonceSize():]
    plaintext2, err := aead.Open(nil, iv2, ciphertext, nil)
    if err != nil {
        panic(err)
    }

    fmt.Printf("%s\n", plaintext2)
}

  1. Rails を参考にした

  2. Rails を参考にした

  3. 他に、暗号化と復号に異なる鍵を用いる公開鍵暗号が存在する

  4. 他に、ビットやバイト単位で逐次暗号化するストリーム暗号が存在する

  5. 細工した暗号文を復号させることで情報を得る選択暗号文攻撃に対して安全になるらしい (よくわかっていない)

  6. AES-GCM の安全性は IV が重複しないことが前提となっている。例示したコードは乱数の生成回数が増えると衝突する可能性があり、本当は怖いAES-GCMの話 - ぼちぼち日記 では IV をカウンター値にするといった対策が挙げられていた。

  7. パスワードのハッシュ化においても、似たような目的で salt と呼ばれる一意な値を付加する。pbkdf2.Key() に渡していた salt がこれにあたる。

  8. 引数 additionalData に渡したデータは暗号文に含まれないが認証の対象にはなる。つまり復号時に同じデータを渡さないと復号に失敗するということだが、ユースケースがよくわかっていない。

ISUCON12 の予選に参加した

今年も unasuke, lime1024 とチーム「たんぽぽの上の刺身」で ISUCON に参加した。このチームでの参加は 3 回目になる。

おととしは ISUCON10 の予選に参加した - きりきりやま で、昨年はあまりにも無力でブログを書くことができなかった。

今年は Ruby 実装で参加して、最終スコアは 3765 点。自分がやったのは以下。

  • app と DB をわける
  • ID 採番を UUID にする
  • MySQL に移行しようとして失敗する
  • シャーディングしようとして失敗する

無力。

MySQL 移行の判断のため、エンドポイント毎にアクセスしているテーブルをスプシにまとめたら便利だった。 ちょっと大変だけど、コードの理解の助けにもなるので来年もやってもいいかもしれない。 毎年、コードの読み込みが足りないと感じる。

3 年目ともなると秘伝のタレが溜まってきて、環境構築はかなり最適化されてきたと思う。 来年は有力になりたい。

2021年

畑をはじめた

昨年、庭にプランターで家庭菜園をはじめたけど、今年は近所の貸農園になった。

家庭菜園むずかしいとか昨年は言っていたんだけど、細かいこと気にしなければほっといたら育つ (というか育ちすぎる) し、物量で殴って最終的に辻褄をあわせるのがよいという結論になった。 この方針でやっていると食べきれない量の野菜ができて困る。

f:id:kirikiriyamama:20220106173628j:plain f:id:kirikiriyamama:20220106173642j:plain f:id:kirikiriyamama:20220106173922p:plain

作業環境がよくなった

ディスプレイアームを買ったりマイクを買ったりした。

f:id:kirikiriyamama:20220106173350j:plain

ディスプレイは左右に並べていたら首が痛くなっちゃったから上下にした。

車を買った

my new gear...

f:id:kirikiriyamama:20220106173305j:plain

人間に水を飲ませた

最近また飲んでいない。

kirikiriyamama.hatenablog.com

スチームオーブンレンジを買った

ボタンがすくなくて操作がむずかしい。性能面はわりと満足していて、特に酒かんモードがいい。

panasonic.jp

これはグリルでつくったからあげ。説明書に従わないとこういうことになる、味はいい。

M1 MBA を買った

はやい。Twitter しかしていないからこれで開発するとどうなるかは知らない。

1U サーバを買った

競馬

日曜日になると TL の全員が競馬やっている世界線にきてしまった。有馬記念 3 連単あたってうれしい。

年間収支はマイナスである。

人間に水を飲ませる技術

妻が水を飲まない。健康のために水を飲むと自ら宣言したのに飲まない1。毎日「今日はこれを飲む」と言って 2L のペットボトルを仕事部屋に持っていくのに 500ml も飲んでいない。https://twitter.com/mtkasr/status/1384856756963135488 を見てぴよログで水を飲んだことを記録しはじめたがそれも 3 日で終わった。

f:id:kirikiriyamama:20210518234835p:plain
リマインドするが無力

常に「今日も水を飲めなかった…」とか言ってるのでとにかく記録させるために物理的なボタンを用意することにした。それで機械にリマインドさせたい。


できたもの。

用意したボタン。

www.switch-science.com

仕組み。

  • M5StickC のボタンを押すと GAS の Web Apps にリクエストが飛ぶ
  • Web Apps で Spreadsheets に水を飲んだことを記録する
  • 水を飲んでいなかったら GAS の cron で Slack にリマインドする

あと、一日のおわりにその日飲んだ量を Slack に流している。Spreadsheets で集計しているんだけど、普段あんまり Spreadsheets 使わないからこれが一番むずかしかった2

M5StickC は Arduino (だいたい C 言語) で実装した。久しぶりの C 言語は文字列をフォーマットするだけでわあ大変ってなった。これが初期のコード。

#include <M5StickC.h>

#include <HTTPClient.h>
#include <WiFi.h>

const char* ssid = "xxxxx";
const char* passphrase = "xxxxx";

const char* url = "https://example.com/";

void setup() {
  M5.begin();

  Serial.begin(9600);
  delay(1000); // wait for serial initialization
  Serial.print("\n");

  M5.Axp.ScreenBreath(10);
  M5.Lcd.setRotation(1);
  M5.Lcd.setTextSize(1);
  M5.Lcd.fillScreen(BLACK);

  M5.Lcd.print("connecting...");
  WiFi.begin(ssid, passphrase);
  // TODO: timeout
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
  }
  M5.Lcd.println("ok");
  Serial.println(WiFi.localIP());
}

void loop() {
  M5.update();

  if (M5.BtnA.wasReleased()) {
    M5.Lcd.print("sending...");

    HTTPClient http;

    http.begin(url);
    http.addHeader("Content-Type", "application/json");
    int code = http.POST("{}");
    Serial.println(http.getString());

    // TODO: follow redirects
    if (code == 302) {
      M5.Lcd.println("ok");
    } else {
      M5.Lcd.println("failed");

      if (code > 0) {
        M5.Lcd.println(code);
      } else {
        M5.Lcd.println(http.errorToString(code).c_str());
      }
    }

    http.end();
  }

  delay(1);
}

周辺の技術がわかっていないのもあり、いまいち一次情報を見つけられなくて苦労した。以下のサイトは日本語で情報がまとまっていてとてもお世話になった。

あと M5StickC で調べるとそんなに情報が出てこないんだけど、中身としては ESP32 という SoC の上で FreeRTOS が動いているので、そのあたりの単語をつかって調べるとよかった。

このあと余計なことをしだして、バッテリー節約のために一定時間操作がなかったら Wi-Fi を切断するとか、送信に失敗したら LED を点滅させる (これを非同期でやりたい、愚直にやると操作をブロックしてしまう) とかやっていたら割り込みや排他処理と向き合わないといけなくなって大変だった。ボタンが手元にないときのために Slack の Slash Commands もつくって、最終的にこうなった3

https://gist.github.com/kirikiriyamama/79c47a74e861e6a16d1b4106c70ec937

これでわりと水を飲むようにはなったんだけど、お昼ごはんのときにいっぱい飲んだり、一日の終わりに駆け込んでむりやり目標を達成したりしていて、そういうことじゃない気がしている。がんばってほしい。


  1. よく知らないけどいっぱい飲むといいらしい

  2. ARRAYFORMULA を使っているせいで GAS で行挿入するコードがむずかしい、もう少しうまいやり方がある気がしている

  3. いま気づいたんだけどラクダとヘビがめちゃくちゃなことになっている

2020年

L2SW を買った

ついにラックマウントタイプの機器を買ってしまった、うるさい。

家庭菜園をはじめた

なんか思っていたよりだいぶ難しくて、まずパラメータがとても多い。 必要になる栄養素がたくさんある上にそれらをバランスのとれた状態に保つ必要があって、例えば窒素が多すぎるとカルシウムが不足して実がぼろぼろになってしまう。 栄養状態を計測することも難しくて、作物の状態を見て少しずつ肥料で調整していくしかない*1。 農薬を使わないとすぐ虫や病気にやられてしまうし、天候にも大きく左右される。 短期間に何回も試行することができないという難しさもある。 農業で生きている人はすごい。

今年は初めてにしてはうまく育てられたと思う、おいしくできた。 大葉などの薬味も一緒に育てて、好きなときにいつでも薬味を収穫できる生活はかなり体験がよかった。

sunsetcellars.jp

正直いままでワインをおいしいとおもったこと一度もなかったんだけど、SUNSET CELLARS は全部おいしい。

chichibubeer.shop-pro.jp

おいしい。

野菜の定期便をはじめた

毎週わりと大量の野菜が届いて、それに合わせて献立を考える生活になった。 呼吸をしていると勝手に野菜が届くのでスーパーにいってあれこれ考えるよりだいぶ楽。 たまに名前も聞いたことのないような野菜が届いてどう食べたらいいかまったくわからなかったりするんだけど、それはそれで楽しい。

数ヶ月の無職期間を経て転職した

無職すごい楽しくて、野菜育てたりコード書いたりしてたらあっという間だった。

いまは BizteX というところで Go を書いている。 LSP の補助に感動しつつ、たまに Ruby の表現力が恋しくなっている。

ISUCON

ISUCON10 の予選に参加した - きりきりやま

ドラム式洗濯機を買った

洗濯物を干す行為が嫌いだったのでうれしい。

洗剤自動投入機能がついているんだけど、これが完全に "顧客が本当にほしかったもの" だった。 洗濯物をいれてボタンを押すだけで済むのはかなり体験がよくて、洗剤をいれる行為がこんなにも面倒なことだなんて思いもよらなかった。 使う前は洗剤が自動で投入されるだけでしょ?としか思っていなかったし、顧客は本当にほしいものを知らなすぎる。

開発環境を Windows にした

ここ数年は Linux をメインにしていたんだけど、デスクトップ環境をあれこれすることにはあんまり興味がなくてとにかくいい感じに動いてほしいと思っていたところに WSL2 が台頭したので、思い切って移行してみた。 久しぶりの Windows、使いやすくなっていてびっくりした。

いくつか気になるところはあるものの*2*3*4、開発環境として問題なく使えているし満足している。 Windows で開発する日がくるなんて思ってもみなかった。

サーバラックを買った

*1:肥料によっては効くまでに 1 ヶ月くらいかかる

*2:フォントが汚い、HiDPI で殴ると解決する

*3:ConPTY がつらい、今後に期待している

*4:WSL と Windows でファイルのやりとりをするのがちょっとめんどう

ISUCON10 の予選に参加した

unasuke, lime1024 とともにチーム「たんぽぽの上の刺身」として、Ruby 実装で参加した。最終スコアは 1302 点。 明確に役割を決めてはいなかったが、結果として自分は主にインフラ周りをみていた。

今年はリモートで参加したが特に不便は感じなかった。Zoom つなぎっぱなしで、あとは GitHub, Scrapbox, Discord でコミュニケーションをとっていた。思いの外画面共有は一度もしなかった。

やったこと

1 週間くらい前

  • 人々と ISUCON8 の予選問題をやりつつ ISUCON 便利ツールについて調べた

当日

開始まで

  • 緊張してきたので縄跳びをする

12:20 - 15:00

  • マニュアルを読む
  • unasuke がデプロイの準備とかしていた
  • alp, pt-query-digest, rack-mini-profiler, rack-lineprof, New Relic をいれた
    • New Relic は新 UI に慣れないのもあってあんまり使いこなせなかった
      • わりと他のツールで確認できる情報で事足りていた
    • alp, pt-query-digest の結果は GitHub の Issues に貼っていた
    • ブラウザで見れるようにしていなかったので rack-mini-profiler は出番がなかった
      • 面倒くさがらずにやったほうがよかったと思う
  • app サーバを 3 台に増やしてみる
    • そんなに効かない
    • top を見るととにかく DB がボトルネックになっている

15:00 - 18:00

  • とりあえず nginx で bot を弾く
    • 動作確認にアクセスログを見ていたらまったく bot からアクセスされていなかったことに気がつく
    • 一旦 revert した
  • POST /api/{chair,estate} の N+1 をなおす
  • nginx の error ログを見ていたら a client request body is buffered to a temporary file が出ていたので適当に client_body_buffer_size を増やす

18:00 - 21:00

  • JOIN していないしテーブルごとに DB わけられそうな気がしてくる
    • アプリはそんなに見ていなかったので人々に確認したら lime1024 が即答してきて助かった
      • 今回、他の人にアプリをほとんど任せられたのはやりやすかった
    • ソシャゲの開発経験がなかったら思いつかなかったかもしれない、ソシャゲはとにかくパーティショニングする
    • app (web), chair DB, estate DB の構成にして 720 点
  • unasuke が app サーバを puma に変更して 840 点
    • これそんなに効くと思っていなかった
  • アクセスログを見ると bot がいたので再び nginx で弾くようにした
    • 多分スコアが低いと出てこないんだと思う
  • New Relic とか切ってベンチマークガチャして 1302 点

2 年ぶりの参加で、以前よりは手を動かせたと思う反面、改めてまだまだ力不足であるとも感じた。最近は金の弾丸で殴れることが多くあんまりこういうことをする機会がないのだけれど、日頃からやってないと難しい。あと最近の MySQL 全然追えてなかった、Generated Columns 便利すぎる。

UTF-8 の文字列をできる限り Shift_JIS に変換したい

Shift_JISCSV で連携する外部サービスがあり、DB では UTF-8 でテキストを持っていたため文字コードを変換する必要が生じた。 ところが UTF-8 に存在する多くの文字は Shift_JIS に対応がないため変換することができない1

そこで、事前に NFKC 形式で Unicode 正規化することで変換可能な文字を増やすことを試みた。 まずは Unicode 正規化の前提として、Unicode の正準等価と互換等価について説明する。

以降の U+16進数 という表記は Unicode のコードポイント (文字に ID のようなものが割り当てられている) を示す。 また、コードポイントに対応する文字の詳細は https://codepoints.net/ といったサイトで確認することができる。

正準等価

例として、ひらがなの「が」について考える。Unicode では「が」を表現する方法が 2 つある。

これらは文字としては同じ「が」を示すが、違うコードで表現されている。このように同じ文字を別のコードで表現するものを正準等価と言う。

また、このとき前者は Shift_JIS に変換できるが、後者は濁点 U+3099 の対応が Shift_JIS になく変換することができない。

ちなみに単体の濁点は U+309B に別に存在していて、これは Shift_JIS に変換することができる。 U+3099 はあくまで他の文字と結合するための存在 (結合文字と呼ばれる) であり、複数のコードポイントを結合して 1 つの文字を表現するような概念は Shift_JIS にはないため結合文字の対応がないのである。

互換等価

次に、単位の cm について考える。これも 2 つの表現がある。

これらは意味としては同じものを指すが、文字列としては異なる表現をされている。このようなものを互換等価と言う。

また、このとき後者は Shift_JIS に対応がない。

Unicode 正規化

さて、Unicode 正規化に話を戻す。Unicode 正規化には 4 種類の正規化形式があるのだが、ここでは先に挙げた NFKC 形式についてのみ考える。 NFKC 形式で正規化すると、互換等価性に基づいて文字が分解され、更に正準等価性に基づいて文字が結合される。

前述の例に当てはめると、組文字の「cm」U+339D はアルファベットに分解されて U+0063 U+006D となる。

また、複数のコードポイントを結合した文字 (結合文字列と呼ばれる) は合成される。「か」と濁点の組み合わせである U+304B U+3099 は「が」を表す U+304C となる。

これはつまり、組文字や結合文字列は正規化によって Shift_JIS に変換することが可能になるということだ。

困ったこと

他にも、例えば全角文字は半角文字に変換される。これは全角文字と半角文字が互換等価であるためだ。 全角の円記号U+FFE5 は半角の円記号 U+00A5 に変換されるのだが、Shift_JIS では半角の円記号が定義されていない。 正規化によって元々は変換できていたものが変換できなくなってしまったのである。

これについては Shift_JIS に変換する際にフォールバックを設定することで回避した。 今のところ問題になっているのは円記号のみで、増えたとしても多くはないであろうことからフォールバックを個別に設定していけばよいと判断した。

まとめ

文字コードを変換する際、NFKC 形式で Unicode 正規化することで Shift_JIS に変換できる文字を増やすことができた。 Shift_JIS に限った話ではないので他の文字コードにも応用できると思われる。

参考

用語の日本語訳は https://www.unicode.org/terminology/term_en_ja.html を参考にした。


  1. 正確には Unicode から JIS X 0208