自分にしか復号/マウントできないVeraCryptでの暗号化を考える

趣味で譜面エディタを作っていたら商業アプリでも使われてました。ぱらつりです。

一応書きたいネタはあったものの、このブログは久しく更新されていませんでした。が、周りはアドベントカレンダーを書いていたりするので重い腰を上げることにしました。

今回は直近に知ったVeraCryptを取り上げます。

VeraCryptは暗号化したドライブを作成できるソフトで、TrueCryptの後継にあたるものです。フリーでこのようなソフトウェアが利用できるのはありがたいですね。

エンジニアのふりをしている人間としては、マジで見られたくないファイルがあったらどのように隠すかなという思考実験(?)をしたくなり、メモがてら考えたことを書こうと思います。

VeraCryptの仕組み

VeraCryptを使うと暗号化したボリュームを作成できる。作り方はこう!という記事は調べればいくらでも出てくるので、少し踏み込んだ部分を見ておきたいと思います。

VeraCryptではデフォルトの暗号アルゴリズムとしてAESが使われています。
AESはアメリカの国立標準技術研究所が公募の中から採用したアルゴリズムで、それ以前まではDESと呼ばれる暗号方式が同様に公募され、標準規格として採用されていました。
いずれの方式も共通鍵暗号、すなわち暗号化と復号に同一のキーを用いるアルゴリズムです。

VeraCryptでは暗号化ボリュームを作成する際にパスワードを設定しますが、このパスワードがファイルの暗号化にそのまま使われているのでしょうか。結論から言えばこれはノーです。

そもそもAESというアルゴリズムでは鍵の長さを128ビット/192ビット/256ビットから選ぶ必要がある一方、ユーザが入力したパスワードは長さが自由であるために何らかの形で鍵を生成する必要があります。

ボリュームの作成時にはマウスカーソルを動かすよう指示されますが、このランダムデータから2種類のキーが生成されます。
1つはマスターキーと呼ばれ、ファイルの暗号化に利用されます。このキーはボリュームの作成時のみに生成される不変のキーです。
もう1つのキーはヘッダキーと呼ばれ、ユーザが入力したパスワード(と、キーファイルがあればこれをパスワードと結合したデータ)からマスターキーを導くために使用されます。

砕けた言い方をすれば、「暗号化ボリュームの作成」とは「マスターキーやサイズなどの情報を含むヘッダデータを作り、これをヘッダキーで暗号化したデータを保存する」処理と言えるでしょう。

作成した暗号化ボリュームをマウントする際には、今の手順を逆に辿ればOKということになります。

具体的には、暗号化ボリュームのヘッダを読み込み、ユーザが入力したパスワード(とキーファイル)からヘッダキーを導き、ヘッダを復号して得られるマスターキーで保存したデータを復号したり、新たなデータを暗号化して書き込むという形になります。

これを簡単な図にすると以下のようになります。
緑色はユーザの手によって入力されるデータ、オレンジ色は暗号化されているデータ、紺はアルゴリズムの出力を表しています。

file

パスワードの変更やキーファイルの変更は、マスターキーを導くためのヘッダキーを書き換え、再度ヘッダデータを暗号化する処理に相当します。

もしパスワードとともにファイルの暗号化に使うキー(マスターキー)も変更するような仕組みの場合、パスワードを変更するごとに全てのファイルを新しいキーで再度暗号化しなければならず、利便性が薄くなります。

したがって、マスターキーが漏れた場合にはパスワードの流出に関係なく暗号化は一切意味を成さなくなります(通常の使い方で心配する必要はありませんが)。

詳細な処理については以下にドキュメントが公開されています。
https://veracrypt.eu/en/docs/encryption-scheme/

ガチ暗号化したかったら?

VeraCryptで暗号化ボリュームを作成すれば安心できるでしょうか。ぼくは不安です。
脆弱なパスワードを設定していればすぐに破られてしまいますし、その一方で人が覚えていられるパスワードなんてたかが知れています。ガチで隠したいようなデータだったらパスワードマネージャに覚えさせるのも抵抗を感じるかもしれません。

少なくとも、総当たり攻撃や辞書攻撃でぱっとは破られなさそうなパスワードを設定したいところです。英数字に記号も混在させたものを生成したいですね。

pwgenというコマンドをインストールすればこんな感じに大量のパスワードを生成してくれます。

 $ pwgen -ys 32 10
i6{rM2P/@jC1y.olsY5T$DL&f#85;ZQk ch*mF18cGT[0:24P,FFclBN)=3RV5sTW
fk-Z,oQ4{znP{C~ilJ'U(XS[R{sq-AJ: 7%MInyLaq!Q0lM5yDA
p)Yc)sRO%qKtAq3rM)v)MNXH\b:^ME+Q xyTa9~X?!JB4JU|)LadrBF$!`|%sZsM^
l`h5vmFQ^rUB=^7.[v=W%B)D:Q3=BfHJ .yk|[email protected]{i'?JQ[b5?BxsZ
5U0.xGU_*WR;>$X'2ZMdVfKP(w([.hx5 c\(9$onWgvCV2sT~6Wf&q

今回は.yk|[email protected]{i'?JQ[b5?BxsZを使うことにしましょう。

これで総当たり攻撃や辞書攻撃には強い暗号化ボリュームが作れますが、このままでは到底覚えておくことはできません。忘れないためにメモなど取ろうものなら本末転倒です。

パスワードを安全に保管したい

ぱっと思いつくのはパスワードをさらに暗号化することです。

opensslを使って暗号化したくなりますね。何なら複数のパスワードで暗号化しても良さそうです。

こんな感じのスクリプトを書けば、好きなだけ暗号化を繰り返すことができます。

#!/bin/bash
set -eu

enc() {
  cat | openssl aes-256-cbc $1 -pbkdf2 -k "$2" #-iv $IV
}

invoke() {
  if [ "$MODE" = "-e" ]; then
    echo "$1" >&2
    cat | enc -e "$1"
  elif [ "$MODE" = "-d" ]; then
    cat | enc -d "$1"
  fi
}

rec() {
  local p
  if [ "$#" -gt 1 ]; then
    p=$1
    shift
    cat | invoke "$p" | rec "$@"
  else
    # last arg
    cat | invoke $1
  fi
}

usage() {
  NAME="$(basename $0)"
  echo "usage: $NAME (-e|-d) [passes...] < input"
  exit 1
}

test $# -gt 0 || usage

MODE="$1"; shift

if [ "$MODE" = "-e" ]; then
  cat | rec "$@"
elif [ "$MODE" = "-d" ]; then
  cat | rec "$@"
else
  usage
fi

ここで以下のようにabcというデータをパスワード000, 111で暗号化したものを逆に111, 000で復号すると、確かに元のabcというデータが出てくることが分かります。

 $ echo abc | ./enc_round_ex.sh -e 000 111 | ./enc_round_ex.sh -d 111 000
abc

ここまで書いておいて何ですが、普通に連結したほうが総当たりに対しては強いですよね???

暗号化されたデータはバイナリデータなので、base64に通せばプレーンテキストに変換して保存できます。

しかし暗号化時点でのデータを見てみると、以下のように先頭にSalted__の文字列が見えます。

 $ echo abc | ./enc_round_ex.sh -e 000 111 | xxd
00000000: 5361 6c74 6564 5f5f c8ad b116 382e 2f8e  Salted__....8./.
00000010: 337e 32f4 ae13 ce15 5c20 d427 a608 1676  3~2.....\ .'...v
00000020: 0ffa 4b87 639e 856a 5479 0e8c 0008 2bcb  ..K.c..jTy....+.
00000030: 7a2e 72a1 5512 af47 80f1 a66a 7cbb f24e  z.r.U..G...j|..N

これはopensslが暗号化に必要なソルトとIVも生成し、このうちソルトをヘッダとして埋め込んでいることを示しています。(IVは与えられたパスワードとソルトから導きます。)

これを見られると「opensslで暗号化したんだな」ということが伝わるので消したいですね。
とりあえず暗号化時には先頭の8バイトを除いて、復号時にはSalted__を付け足すようにスクリプトを改変します。

   if [ "$MODE" = "-e" ]; then
     echo "$1" >&2
-    cat | enc -e "$1"
+    cat | enc -e "$1" | dd bs=1 skip=8 status=none
   elif [ "$MODE" = "-d" ]; then
-    cat | enc -d "$1"
+    cat | sed '1s/^/Salted__/' | enc -d "$1"
   fi

パスワードを暗号化しましたが、もう一ひねり欲しいですね。欲しくないですか?

今ある暗号化データはopensslによる出力ですが、ソルトもいじってしまいましょう。暗号化データに対してバイト単位でXOR演算をしたいと思います。場所とXORをかける値はお好みで。復号時には同じ場所にXOR演算をかければ元に戻ります。

C#でざっくりと書いてみます。

using System;
using System.Linq;
using System.IO;

class Program
{
  static void Main(string[] args)
  {
    if (args.Length == 0 || args.Length % 2 != 0)
    {
      Console.Error.WriteLine("usage: bxor [pos1] [val1] [pos2] [val2]...");
      Environment.Exit(1);
    }
    var offsets = args.Where((p, i) => i % 2 == 0).Select(p => int.Parse(p));
    var val = args.Where((p, i) => i % 2 != 0).Select(p => (byte)Convert.ToInt32(p, 16));
    var insts = offsets.Zip(val, (p, q) => new { Offset = p, Val = q }).ToList();
    int max = insts.Max(p => p.Offset);
    using (var stdin = Console.OpenStandardInput())
    using (var stdout = Console.OpenStandardOutput())
    {
      byte[] buffer = new byte[Math.Max(max, 1024)];
      int pos = 0;
      int bytes;
      while ((bytes = stdin.Read(buffer, 0, buffer.Length)) > 0)
      {
        if (pos == 0)
        {
          foreach (var item in insts) buffer[item.Offset] ^= item.Val;
        }
        stdout.Write(buffer, 0, bytes);
        pos += bytes;
      }
    }
  }
}

monoでコンパイルして、0埋めされたデータに対して以下のように実行すると指定した場所にXOR演算が施されていることがわかります。

 $ dd if=/dev/zero bs=1 count=10 status=none | bxor.exe 0 0xff 4 0x36 | xxd
00000000: ff00 0000 3600 0000 0000                 ....6.....

これをopensslによる暗号化の後に挟むことで、そもそもsaltが正しくないので総当たり攻撃をしても意味を成さない状態になりました。

シェル上で実行する際の注意

シェル上でそのまま実行すると、引数として与えたキーが履歴に残ってしまいます。

そこで以下のようにcatを使って一度シェル上で変数に入力し、これをスクリプトに渡す方法が考えられます。

 $ PW="$(cat)"
000 111
 $ XOR="$(cat)"
0 0xff
cat raw | base64 -d | eval "bxor.exe $XOR" | eval "enc -d $PW"

さて、これで十分な気がしますが、万が一にもパスワードまで復号されたら大変です。
最後に平文のパスワードをいじっておきたいと思います。

ここでは、シンプルなルールを復号したパスワードに適用し、実際のパスワードとして使うこととします。

例えば、今回決めたパスワード.yk|[email protected]{i'?JQ[b5?BxsZに対して、実際に暗号化に利用するパスワードを.yk|[email protected]{i'?JQ[b5?Bxszとしてみます。
ここで適用したルールは以下の2つです。

  • 最初に出現した数字を1増やす
  • 最後の文字を小文字にする

つまり、パスワードを復号しても実際に使うときにはこの反対の手順

  • 最初に出現した数字を1減らす
  • 最後の文字を大文字にする

を踏まなければなりません。もちろん他のルールもいろいろと思いつくかと思います。
これでもしパスワードの復号まで進んだとしても、時間を稼ぐことができそうです。

これくらい手順を踏めば、死ぬと同時にHDDを消去するソリューションもいらなくなるかもしれません。

まとめ

VeraCryptの仕組みとガチ暗号化について考えました。

調べる中で見つけたリファレンスには、ボリュームの構造や実際の復号の手順も紹介されていたのでシンプルに学びになりました。TrueCrypt時代のドキュメントをIPAが日本語訳したものが公開されていたので、こちらの方が読みやすいかもしれません。 https://www.ipa.go.jp/security/jisec/apdx/documents/AGD.pdf

VeraCryptを他の記事で調べたとき、「キーファイルはパスワードの代わりに使える」と解釈して「キーファイルだけでマウントできないじゃん」という状態になりましたが、ちゃんとドキュメントを読んだら理解が間違っていたことに気づきました。リファレンスを見るのは大事ですね。

パスワードの暗号化はここまでやったらやりすぎな気もしますが、やろうと思えばこれくらいのステップは踏めるのではないかという思考実験でした。
数学的な考察はちゃんとやっていないのであまり真には受けないでください。

Leave a Comment

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です