データをセーブする(3)(Unityメモ)
前回からの続き。やっとC#とUnityの出番
セーブデータの暗号化
セーブデータのセキュリティ
情報セキュリティの要素の記事を再掲します。
これを参考に、セーブデータ処理についてもセキュリティの要素を考えてみました。
・機密性:暗号化したセーブデータを解読・不正に復号されない。
→秘密鍵を秘匿する
・非改ざん性:改ざんされていない。
→改ざん検出により不正な暗号文を受理しない
・真正性:なりすましを受け付けない。
→秘密鍵による署名をつける。システム、ユーザ共に必要
・可用性、信頼性:セーブデータの元情報が破損しない。
→実装をがんばる(アバウト)
非改ざん性については上記の要素にはありませんが、セーブデータ処理については特に重要なため分離しました。ただし改ざん検出だけでは改ざんの防止自体を阻止できないため、セキュリティとしては弱いです。また秘密鍵の秘匿についてもスタンドアロン構成では完全ではないため、セキュリティの機密性と真正性も弱いです。
なおスタンドアロンの場合は、情報セキュリティの要素のうちいくつかを達成できません。
・非否認性、責任追跡性:認証局による記録・照合を実現できないため達成不能
・完全性:非否認性が達成できないため達成不能
認証モデルとの類似
セーブ処理について、通常はシステムとユーザの間のauthentication(システムがユーザに使用許可を与える)として扱います。しかし三者間認証のようにとらえることもできます。
オンライン構成の場合は外部サーバが認証局の役割となるので、一般的な第三者認証の仕組みで十分です。スタンドアロンの場合でも、セーブ処理のやり取りに時間軸を導入し、セーブ時点のシステムとロード時点のシステムを区別することで、三者間認証の図式とセーブ処理の図式を対応付けることができます。
ただしスタンドアロンの場合はcertification(認証局からユーザに「お墨付き」を与える)が機能しません。認証局が機能するためには証明書の保存と照合の機能が必要ですが、証明書をシステム内部、つまりアセンブリ上に保存することができないためです(保存できたとしても無限の時間をかければアクセス・改ざん可能)。
初期化ベクトルの問題と改ざん検出
スタンドアロンの場合、初期化ベクトルの再利用という状況は変えられません。そのため初期化ベクトル再利用による改ざんについては対策を諦めて、暗号文の改ざん検出で代用することにします。改ざん検出の方法には秘密鍵を用いるメッセージ認証符号(MAC)を用います。
データ構造
データ構造と処理の手順はだいたい表裏一体です。データ構造が決まると処理の順序や実装も決まりますし、実装が難しい場合はデータ構造を変えたりもしました。まだ設計段階という事情もありますが。というわけで、実装方法と相談しながらデータ構造をとりあえず決めました。
署名(未実装)
第三者認証による証明書には、certificationの機能として認証局と証明者の署名が添付されます。一方でスタンドアロン構成でのセーブデータの場合、certificationが機能しないため、署名をつける意味合いが薄いです。署名をシステム側に保存できず照合できないのと、システムの署名をまるごとユーザが複製できるためです。そのため現時点ではセーブデータに署名はつけていません。ゲームシステムをオンライン構成にした場合は署名を入れると思います。
あるいはデータ本体のMAC(メッセージ認証符号)を署名として扱うのが良いかもしれません。少なくともシステム側のMACは真正性が保てます。
暗号化、特に暗号利用モード
暗号化手法についてはブロック暗号化アルゴリズムと暗号利用モードに分かれます。ブロック暗号化アルゴリズムについてはAES-256を使います。暗号利用モードの選択は、結局はCBCを前段、ECBを後段の2段構成にしました。
暗号利用モードについては、初期化ベクトルの再利用による脆弱性がないものを用います。このため、OFB, AES-GCMを含むCTR系のモードは除外です。残りはECB, CBC,CFBの系統になるのですが、やはり一長一短があります。
ECBの長所:初期化ベクトルを使わないので初期化ベクトルの脆弱性はない
ECBの短所:平文パターンの撹拌が出来ない
CBC系の長所:平文パターンの撹拌が出来る
CBC系の短所:
・初期化ベクトルが不正でも先頭ブロックを除いて復号が可能
・初期化ベクトルの再利用時に1ブロックおきに改ざんが可能
ただし、CBC系の前者の短所については、うまく使えば短所にならないかもしれません。下の記事を読んで気づきました。
つまり、先頭ブロックをあえてダミーにしてCBCで復号する、という処理にすることで、初期化ベクトルを使い捨てにでき、セーブデータに残す必要がなくなります。復号に鍵が必要なのはそのままなので、暗号化の強度は特に変わっていません。後者の短所についてはどうしようもないのですが、ストリーム暗号系では全ブロックを改ざんできるため、「CBCで暗号文の半分は改ざんを防げる」とも言えます。圧縮データを暗号化するので、復号を1ブロックごとに無効化できるだけでも多少の防御になると思います。まあ気休めですが。
結局のところ最善の策はありません。そのため、モードを組み合わせて対策とすることにしました。現状ではECBとCBCを組み合わせるつもりです。CBCはパターンの撹拌用と割り切り、初期化ベクトルの問題がないECBをメインの暗号化に使います。
ECBの鍵かCBCの鍵の片方を入手されたときの安全性を考えると、復号時にECBを先、CBCを後に行う方が安全性がまだましでした。結果として、セーブデータの暗号化時は前段にCBC、後段にECBを用います。「撹拌してから暗号化」という分かりやすい順序になりました。
機密性検証
復号処理でデータが破損していないかを検証します。処理の誤り検出がメインですが、上記の改ざんの検証にもなります。
暗号化の前段の出力に対してSHA-256を計算・付加します。この部分の実装としては、機密性検証の処理に加えて、ダミーブロックの追加・削除も行います。
非改ざん性検証
certificationおよび署名はスタンドアロンで機能しないものの、秘密鍵を用いたメッセージの非改ざん性検証は可能です。具体的にはメッセージ認証符号(MAC)としてHMAC-256を用います。
MAC関数とハッシュ関数との違いは、MACの方は秘密鍵を使って計算する、という点です。通常のハッシュ関数では改ざんしたデータ列ごとハッシュ値を計算することでハッシュ値を改ざんできますが、MACの計算では鍵を秘匿することでMACの改ざんを防ぐことができます。
平文を改ざんする場合、暗号文も改ざんする必要があります。そのためMACは平文ではなく暗号文に対して計算します。MACが一致しない暗号文は復号を拒否する、という方針です。
本当はCMACを使いたかったのですが、 .NETの公式ライブラリに実装されていませんでした。代わりに実装のあるHMACを使います。
鍵の生成(未実装)
秘密鍵(厳密には共通鍵)の生成方法はPBKDF2を使います。ただしマスターパスワードとソルトはセーブデータ処理のソースコードに残さず、生成した秘密鍵を残します。できれば直書きではなく外部ファイルに分けたいです。
現状の実装
ストリーム操作
暗号化や復号ではデータ列を数段階にわたって処理します。この「データ列を受け渡す」という概念をストリームとよびます。
.NETにもストリームを実装したクラスがあり、これを使うことでコードが多少読みやすくなります。.NETの場合は「ストリームに指定バイト数だけ書き出す」というメソッドがないのが難点ですが…
あと暗号化ライブラリはストリームで実装されているので、ストリームを使わざるを得ないという事情もあります。
CryptoStreamのコンストラクタには暗号化アルゴリズムを指定します。AESを使用します。
ちなみにAesManagedやRijndael, RijndaelManagedなどの派生クラスは .NET 6(C#10.0)からobsoleteのようです。
CryptoStreamの使い方の注意点として、引数のストリームには暗号化時も復号時も暗号化データのストリームを指定します。
//暗号化。 入力(平文) → 暗号化ストリーム → 出力(暗号文)
using (var CBCStream
= new CryptoStream(cryptoDestStream, cryptoTransform, CryptoStreamMode.Write, true)
)
{
sourceStream.CopyTo(CBCStream); //ソースから暗号化処理へコピー
}
//復号。入力(暗号文) → 暗号化ストリーム(復号) → 出力(平文)
using (var CBCStream
= new CryptoStream(cryptoSourceStream, cryptoTransform, CryptoStreamMode.Read, true)
)
{
CBCStream.CopyTo(destStream); //暗号化処理から出力(destination)へコピー
}
復号時はCryptoStreamに出力(平文)ストリームを指定したくなるのですが、CopyToの向きを考えるとこの書き方が自然です。
またCryptoStreamの最後の引数は、処理後に引数のストリームを開いたままにする(leaveOpen=true)ものです。これがないと処理後に引数のストリームが閉じてしまい、後段で読み込めなくなります。
Create-Disposeパターン
ストリーム処理の実装では、有限のリソースを確保→処理本体を実行→リソースを開放、という手順をよく使います。これがCreate-Disposeパターンとしてパターン化されています。.NETでは usingステートメントという仕組みでCreate-Disposeパターンを実装しやすくなっています。特にDispose()を暗黙で実行してくれるので、Dispose()の書き忘れがなくなるのが良い点です。
.NETのusingステートメントは入れ子にしなくても書けます。このためネストが深くならず、コードが読みやすくなります。
usingステートメントの連鎖の例が分かりづらいのですが、要するにただ並べているだけです。
using (var a=new A()) using (var b=new B()) using (var c=new C())
{
//処理の本体
}
なお、Unity 2018.4で使われるC#のバージョンは C# 7.3なので、using declarationには対応していません。
作ったコードの抜粋
public class StorageAES : My.Storage.ICryptor
{
//メンバや各種ヘルパ関数の定義は省略
public byte[] Encrypt(byte[] data)
{
using (var sourceStream = new MemoryStream(data))
using (var destStream = new MemoryStream())
{
EncryptStream(sourceStream, destStream);
return destStream.ToArray();
}
}
public byte[] Decrypt(byte[] data)
{
using (var sourceStream = new MemoryStream(data))
using (var destStream = new MemoryStream())
{
DecryptStream(sourceStream, destStream);
return destStream.ToArray();
}
}
//データ長はAES側で付与するため、データ長の付与を自前で実装する必要なし
public void EncryptStream(Stream sourceStream, Stream destStream)
{
using (var CBCinStream = new MemoryStream())
using (var ECBinStream = new MemoryStream())
using (var ECBoutStream = new MemoryStream())
{
AddEnvelope(sourceStream, CBCinStream); //エンベロープ(ダミーブロックとタグ)を付加
EncryptCBC(CBCinStream, ECBinStream, AgitationKey); //撹拌
EncryptECB(ECBinStream, ECBoutStream, EncryptionKey); //暗号化
SignPayload(ECBoutStream, destStream); //MACを付加
}
}
public void DecryptStream(Stream sourceStream, Stream destStream)
{
using (var ECBinStream = new MemoryStream())
using (var CBCinStream = new MemoryStream())
using (var CBCoutStream = new MemoryStream())
{
VerifyPayload(sourceStream, ECBinStream); //検証してMACを削除
DecryptECB(ECBinStream, CBCinStream, EncryptionKey);
DecryptCBC(CBCinStream, CBCoutStream, AgitationKey);
RemoveEnvelope(CBCoutStream, destStream); //エンベロープ(ダミーブロックとタグ)を削除
}
}
private void EncryptCBC(Stream sourceStream, Stream cryptoDestStream, byte[] key)
{
using (var aesCBC = Aes.Create())
{
aesCBC.Mode = CipherMode.CBC;
aesCBC.KeySize = KEY_LEN;
aesCBC.BlockSize = BLOCK_LEN;
aesCBC.Padding = PaddingMode.ISO10126; //ランダム
aesCBC.Key = key;
aesCBC.IV = CreateOnetimeIV(BLOCK_BYTE_LEN); //初期化ベクトルは使い捨て
ICryptoTransform cryptoTransform = aesCBC.CreateEncryptor(aesCBC.Key, aesCBC.IV);
//CBCで暗号化
using (var CBCStream = new CryptoStream(cryptoDestStream, cryptoTransform, CryptoStreamMode.Write, true))
{
sourceStream.Position = 0;
sourceStream.CopyTo(CBCStream);
}
}
}
private void DecryptCBC(Stream cryptoSourceStream, Stream destStream, byte[] key)
{
using (var aesCBC = Aes.Create())
{
aesCBC.Mode = CipherMode.CBC;
aesCBC.KeySize = KEY_LEN;
aesCBC.BlockSize = BLOCK_LEN;
aesCBC.Padding = PaddingMode.ISO10126; //ランダム
aesCBC.Key = key;
aesCBC.IV = CreateZeros(BLOCK_BYTE_LEN); //復号時のIVは任意(先頭ブロックのみ復号できない)
ICryptoTransform cryptoTransform = aesCBC.CreateDecryptor(aesCBC.Key, aesCBC.IV);
//CBCで復号
cryptoSourceStream.Position = 0; //直前にCopyTo()されている場合など、Positionがゼロでない場合があるため、開始位置を明示する
using (var CBCStream = new CryptoStream(cryptoSourceStream, cryptoTransform, CryptoStreamMode.Read, true))
{
CBCStream.CopyTo(destStream);
}
}
}
/* ECBの暗号化と復号は省略 */
private void AddEnvelope(Stream sourceStream, Stream destStream)
{
//暗号文にダミーブロックとタグを付加
//Streamの場合、単にWriteするだけでデータ列を付加できる
//先頭にダミーブロックを追加
byte[] dummyBlock = CreateNonce(BLOCK_BYTE_LEN);
destStream.Write(dummyBlock);
//タグを追加
byte[] tag = CalcSHA256(sourceStream);
destStream.Write(tag);
//本体を書き込む
sourceStream.Position = 0;
sourceStream.CopyTo(destStream);
}
private void RemoveEnvelope(Stream sourceStream, Stream destStream)
{
//復号文から先頭のダミーブロックとタグを削除
//先頭を読み飛ばすことでダミーブロックを削除
//タグを読む
byte[] given_tag = new byte[CHECK_BYTE_LEN];
sourceStream.Position = BLOCK_BYTE_LEN;
sourceStream.Read(given_tag, 0, CHECK_BYTE_LEN);
//タグを検証
byte[] data_tag = CalcSHA256(sourceStream, BLOCK_BYTE_LEN + CHECK_BYTE_LEN);
if (given_tag.SequenceEqual(data_tag))
{
//データ本体を出力
sourceStream.Position = BLOCK_BYTE_LEN + CHECK_BYTE_LEN;
sourceStream.CopyTo(destStream);
}
else
{
throw new System.UnauthorizedAccessException();
}
}
/* SignPayload() と VerifyPayload() は省略。
* AddEnvelope(), RemoveEnvelop()と同様
*/
private byte[] CalcSHA256(Stream sourceStream, long position = 0)
{
using (var sha = SHA256.Create())
{
sourceStream.Position = position;
return sha.ComputeHash(sourceStream);
}
}
private byte[] CalcHMAC256(Stream sourceStream, byte[] key, long position = 0)
{
using (var hmac = new HMACSHA256(key))
{
sourceStream.Position = position;
return hmac.ComputeHash(sourceStream);
}
}
}
次にやる事
・イベントハンドラの実装
・ストリームの非同期処理化(await using(…) を使う)
・ストレージ内の他の要素もストリーム化(気が向いたら)
・IndexedDBへのセーブ・ロード
この記事が気に入ったらサポートをしてみませんか?