ユーザが手軽にセーブデータを改ざんできないようにする方法

More than 1 year has passed since last update.

まえがき

まあタイトルがなんかデカそうに「セーブデータ」って書いてあるけど実際には Int 型のみを想定しています。この方法に手を加えたら多分 String 型とかもできないことはないかもしれませんが面倒くさそうなので自分はやりません。なお、手軽に利用できるように GitHub にはフレームワークとして公開しているのでどうぞご自由にご利用ください(ちなみにライセンスは伝説の WTFPL ライセンスですので冗談抜きで「お好きにどうぞ」です)。

ちなみに想定用途はどっちかというと多分ソシャゲとかでキャッシュとしてローカルにセーブデータを残すときくらいかな?なぜならばまずユーザのセーブデータとして、一番便利にローカルに保存する方法としては NSUserDefaults を使う方法です。しかしこれではデータの暗号化はされておらず、すべて平文のままでセーブされてしまいますので、もしユーザが何かしらのツールを使ってセーブデータを自分の端末から読み書きできるようにしたら、極端な話、例えば RPG ですとユーザレベルがまだ5にしか上がってないのに手動でいきなりレベル100とかに改ざんしたりすることも非常に容易いのです。ですので、なにかしらの方法でデータを暗号化して保存しなくてはなりません。もちろん既存の方法として RSA やらの方法もありますがそれはそれでなんか大げさな気がしなくもないのでもう少し手軽な方法がほしい、という思いからこのアルゴリズムを考察しました。

なお一応事前に断っておきますがこれはなにか新しい暗号化アルゴリズムってわけではありません。あくまで既存のアルゴリズムをうまく組み合わせてできた手軽に使える方法です。

課題

  1. 暗号化に使う鍵。共通鍵でも公開鍵・秘密鍵でもOKですが重要なのはユーザには取得できず、できれば違うユーザには違う鍵が作られること。となるとどっちかというと多分共通鍵方式のほうが手軽だし便利そう。
  2. 暗号化に使うアルゴリズム。重要なのはユーザにどのように暗号化しているのかそう簡単に推測されないこと。(でないと上記の鍵を割り出すことも可能になります)
  3. 平文化に使うアルゴリズム。そりゃあデータを暗号化したまま解読できなければセーブデータとして意味が無い。
  4. 本当にデータの改竄がないかどうか確認するアルゴリズム。まあこれが目的ですもんな。

アプローチ

まず上記の1.の問題について、最初に思いついたのは何かしらのランダム関数だったが、問題はそれではどこかに保存しなければ再度同じ値が生成される確率は極めて小さく鍵として成り立たない、かと言って保存しちゃうとそれがユーザの目に触れることになるからそれも鍵として意味がありません。そこでいろいろ考えてみたら、やはりデバイスごとに発行される UUID が非常に都合がいいことに気づきました。ちなみに一言「UUID」って言っても、最近はプライバシー保護の観点でデバイス特定ができちゃう昔の「UDID」が使えなくなり、その代わりに様々な用途にあわせてそれぞれ違う UUID が発行されることができちゃいます。ここでこの用途に一番ぴったり合うのは UIDevice.currentDevice().identifierForVendor です。デバイスごとに違う UUID が生成されるだけでなく、同じデバイスでもベンダーごとに違うため、ユーザが自分のアプリに発行される UUID を再現することは基本不可能と考えます。しかし、UUID は文字列ですのでそのままでは使いにくいです。でもところが、これは NSObject オブジェクトですので Int 型の戻り値を持つ hash が使えちゃうことに気づきました。これならデバイスごとに変わるが、アプリ削除されない限りずっと同じ値が生成されるのでローカルに保存する必要もありません。というわけで、今回はこの identifierForVendor のハッシュ値を共通鍵にします。

次に2.の問題ですが、要するに暗号化されたデータがグチャグチャなほどよろしいのです。これもいろいろ考えましたが最終的に採用した案は上記の共通鍵を保存したい値でかけます(ただしオーバーフローの危険性を考えて * ではなく &* で)。でもこれだけでは結局ユーザは幾つかセーブデータを比べて法則性でアルゴリズムを推測できそうですので何とかグチャグチャにしないといけません。なのでとりあえず再度 hash すればいい…と思ったが Swift では Inthash は自分自身でしたのでこのままではなんの意味もありません。しかしところが Inthash は自分自身でも、IntString として書き出しちゃえば Stringhash は自分自身じゃないのでこれでグチャグチャにできちゃいます。うむ我ながらナイスアイデアでございます。

そして3.の問題。当然 hash ですから一方通行です。元の値にはそのまま戻せません。となるとどこかで元の値を簡単に割り出せる情報を入れないといけません。そこで考えついたのが bit mask という手法です。どういうことかというと先ほど出来上がったグチャグチャの値の一部を元の値そのままにすることです。ゲームでユーザパラメータで使う Int の数値でしたらだいたい 16 bit 以内で収まる可能性が非常に高いので(16じゃあ足りなくても少しずつ桁数を増やしてあげればいいし、さすがに例えばユーザキャラクターのレベルが UInt32.max になるなんて普通ないでしょ…)、というわけで先ほどのグチャグチャの値のバイナリ下16桁を実際の値に書き換えちゃいます。これならむしろ先ほど出来上がった hash 値すら一部だけになってしまって更に復元不可能になってしまいます。

最後の4.の問題。当然3.でそのまま元データを入れちゃうとユーザが「ここが実際の数値だ!」と気づく可能性も高いので、改ざんしたくなる気持ちもなるでしょう。なので絶対何がどうであれたとえ本当にユーザが「この部分は実際の値だ!」と気づいたとしても改ざんしたら無効の値になるようにしなければなりません。ところがここまで来るともはやこの問題はすでに解決済みです。なぜならばその読み取れた値をもう一回今までと同じプロセスで再度暗号化データ作らせればいい。ここでもし同じ暗号化データが出来上がったら白、できなかったら黒、という単純明快なことです。何故ならばすでに暗号化の時に共通鍵を実際の値をかけてできた結果を文字列になおしてハッシュ化しました。もしここで共通鍵とかける数値が違う数値でしたら出来上がったハッシュ値も違うものになります。なにせハッシュの便利なところは一方通行だけでなく、少しでも入力が違ったら出力はとてもとても違うものになってしまうてんですからね。ですので identifierForVendor さえユーザにバレない限り、たとえ暗号化の手順がバレたとしてもユーザは簡単には有効な改ざん値を作れないので非常に有効ではないかと思います。(まあバレたらバレたで共通鍵の使い方をもう少し工夫して、例えば今回は &* だけだったがこれをもう少し複雑な計算式にすればいいわけだから)

追加課題

コメントに頂いた懸念点として、このままですと例えばセーブ項目に
- 攻撃力 800
- 防御力 100
とあればコピペだけで
- 攻撃力 800
- 防御力 800
に改ざんすることが結局できちゃいますので、もう少し手を加えなければなりません。というわけでここで更に「識別子」という項目を導入しちゃおうと思います。例えば「この項目は UUID のハッシュ以外にも更にこの値をかける」とか言う方法ですね。これでしたら違う項目に違う識別子をつけれあげれば、数値のコピペでは使えません。

ソースコード

とりあえずまあ Swift だしオブジェクトとして重くないし struct で作ります。

import UIKit

/// Use this struct to encrypt and decrypt Int values.
///
/// To initialize just juse the syntax below:
///
/// `let endecrypter = ESCEnDecrypter()` or `let endecrypter = ESCEnDecrypter(bitMaskDigit: 12)`
///
/// - Parameter bitMaskDigit: The number of digits of the bit mask. Default value is 16, which generates the mask of 0xFFFF
public struct EnDecrypter {

    /// Method Errors.
    enum Error: ErrorType {
        case IntValueTooBigToEncrypt(plainInt: Int, bitMask: Int)
        case InvalidValue(value: Int)
    }

    private let hashedUUID = UIDevice.currentDevice().identifierForVendor?.hash ?? 1
    private let bitMask: Int

    /// Initializes the ESCEnDecrypter instance.
    ///
    /// - Parameter bitMaskDigit: The number of digits of the bit mask. Default value is 16, which generates the mask of 0xFFFF
    public init(bitMaskDigits: Int = 16) {
        let bitMask = [Int](0 ..< bitMaskDigits).reduce(0) { $0 + (1 << $1) }
        self.bitMask = bitMask
    }

    /// Use this method to encrypt the value.
    ///
    /// - Parameter plainInt: The input value you'd like to encrypt. Notice that this value should be able to get the same value after applying the bit mask (which simply means the input value should be smaller than the bit mask).
    ///
    /// - Parameter identifier: An additional identifier for encrypting data. You may set different identifiers for different parameters you're encrypting so that the same value for different parameter can get a different encrypted value. Default identifier value is 1.
    ///
    /// - Throws: `Error.IntValueTooBigToEncrypt(plainInt: Int, bitMask: Int)` if the `plainInt` is greater than the `self.bitMask`.
    ///
    /// - Returns: The encrypted Int value.
    public func encrypt(plainInt: Int, withAdditionalIdentifier identifier: Int = 1) throws -> Int {

        guard (plainInt & self.bitMask) == plainInt else {
            throw Error.IntValueTooBigToEncrypt(plainInt: plainInt, bitMask: self.bitMask)
        }

        let hashedInt = (self.hashedUUID &* identifier &* plainInt).description.hash
        let hashMask = ~self.bitMask
        let encryptedInt = (hashedInt & hashMask) + plainInt

        return encryptedInt

    }

    /// Use this method to decrypt the value.
    ///
    /// - Parameter plainInt: The input value you'd like to decrypt.
    ///
    /// - Parameter identifier: An additional identifier for decrypting data, which should be the same as the one you used to encrypt it. Default identifier value is the same as `encrypt` method's value which is 1.
    ///
    /// - Throws: `Error.InvalidValue(value: Int)` if it can't re-encrypt the decrypted value to self, and other errors that `encrypt` may throw.
    ///
    /// - Returns: The decrypted Int value.
    public func decrypt(encryptedInt: Int, withAdditionalIdentifier identifier: Int = 1) throws -> Int {

        let plainInt = encryptedInt & self.bitMask

        guard try self.encrypt(plainInt, withAdditionalIdentifier: identifier) == encryptedInt else {
            throw Error.InvalidValue(value: encryptedInt)
        }

        return plainInt

    }

}

これでまあ使い方も一目瞭然かな?とりあえずまずは Instance 作って、あとは encrypt(plainInt: Int) で暗号化したデータを作り、decrypt(encryptedInt: Int) で暗号化されたデータを復元して有効かどうかを検証します。とりあえず簡単な使用例貼ります:

let endecrypter = EnDecrypter() //Instance を作る

let userLevel = 5
let userLevelIdentifier = "User Level".hash
do {
    let encryptedUserLevel = try endecrypter.encrypt(userLevel, withAdditionalIdentifier: userLevelIdentifier) //ユーザレベルの暗号化された値
    let decryptedUserLevel = try endecrypter.decrypt(encryptedUserLevel, withAdditionalIdentifier: userLevelIdentifier) //ここで 5 で返される

    let falsificationUserLevel = encryptedUserLevel + 95 //これで bit mask を適用するとできた値が 100 になる
    let anotherDecryptedUserLevel = try endecrypter.decrypt(falsificationUserLevel) //ここで無効なデータと判断され、EnDecrypter.Error.InvalidValue が投げられる
} catch let error {
    print(error)
}

let userOffensivePower = 100
let userOffensivePowerIdentifier = "User Offensive Power".hash
do {
    let encryptedUserOffensivePower = try endecrypter.encrypt(userOffensivePower, withAdditionalIdentifier: userOffensivePowerIdentifier) //ユーザ攻撃力の暗号化された値
    let yetAnotherDecryptedUserLevel = try endecrypter.decrypt(encryptedUserOffensivePower, withAdditionalIdentifier: userLevelIdentifier)//ここで無理矢理攻撃力の暗号化された値をユーザレベルの方にコピーしてもユーザレベルの識別子で無効なデータと判断され、EnDecrypter.Error.InvalidValue が投げられる
} catch let error {
    print(error)
}

あとがき

まあとりあえず利用自体はかなり手軽だし計算もそれほど重くはないと思いますが、もし何かもっといい方法あれば教えて下さい。あとアプローチにも言ったとおり、ユーザが自分がリリースしたすべてのアプリを削除したり、もしくは自分のセーブデータを別のデバイスに移しちゃうと、identifierForVendor が変わってしまうので、これはどっちかというとローカルにセーブデータのキャッシュを作るときに適した方法であり、必ず自分のサーバなりなんなりで正しい情報を保存しておく必要があります。でないと正規のセーブデータにもかかわらず無効なデータとして認定されます。