Unity実践:PlayerPrefsにJSONで複数セーブスロットを保存し、必要に応じて暗号化する

この記事でできること

  • PlayerPrefs に JSON文字列 として 複数スロット のセーブデータを保存・読み出し
  • オプションで 暗号化(AES) + 改ざん検知(HMAC) に対応
  • 「とりあえず動く」から「最低限の安全性」まで、段階的に導入できる

前提:保存するデータ構造

JsonUtility を使うので publicフィールド を持つ [Serializable] なクラスにします。

[System.Serializable]
public class GameData
{
    public int    level = 1;      // 1〜99
    public float  score = 0f;
    public string item  = "なし";
}

1. 複数スロットをJSON文字列で保存(暗号化なし)

方針

  • スロットごとに PlayerPrefs のキーを分ける:SaveSlot.{slot}
  • 最後に使ったスロットを覚える:LastSaveSlot
  • JSONはそのまま文字列で格納
using UnityEngine;

public static class PrefJsonSlots
{
    const string SlotKeyPrefix = "SaveSlot.";
    const string LastSlotKey   = "LastSaveSlot";

    static string SlotKey(int slot) => $"{SlotKeyPrefix}{slot}";

    public static void SaveToSlot(int slot, GameData data)
    {
        // 例: バリデーション(必要なら)
        if (data.level < 1 || 99 < data.level)
            throw new System.Exception("レベルは1〜99で指定してください");

        string json = JsonUtility.ToJson(data);
        PlayerPrefs.SetString(SlotKey(slot), json);
        PlayerPrefs.SetInt(LastSlotKey, slot);
        PlayerPrefs.Save();

        Debug.Log($"[PrefJsonSlots] Saved slot={slot} json={json}");
    }

    public static bool TryLoadSlot(int slot, out GameData data)
    {
        string key = SlotKey(slot);
        if (!PlayerPrefs.HasKey(key))
        {
            data = null;
            return false;
        }
        string json = PlayerPrefs.GetString(key);
        data = JsonUtility.FromJson<GameData>(json);
        return data != null;
    }

    public static int GetLastSlotOrDefault(int defaultSlot = 1)
    {
        return PlayerPrefs.GetInt(LastSlotKey, defaultSlot);
    }

    public static void DeleteSlot(int slot)
    {
        PlayerPrefs.DeleteKey(SlotKey(slot));
        PlayerPrefs.Save();
    }
}

使い方(例)

// セーブ
var data = new GameData { level = 12, score = 3456.7f, item = "勇者の剣" };
PrefJsonSlots.SaveToSlot(2, data);

// ロード
if (PrefJsonSlots.TryLoadSlot(2, out var loaded))
{
    Debug.Log($"Loaded slot2 → L{loaded.level}, S{loaded.score}, I:{loaded.item}");
}

2. 暗号化(AES)+改ざん検知(HMAC)に対応する

なぜ必要?

  • PlayerPrefsは平文のため覗かれ・改ざんされやすい
  • チート対策の第一歩として、暗号化改ざん検知(署名/HMAC)を導入

注意:完璧なチート対策ではありません(キーがアプリ内にあるため)。サーバ連携や本格的なアンチチートは別途検討が必要です。

実装方針

  • 暗号化:AES-CBC(初学者向けに実装しやすい)
  • 改ざん検知:HMAC-SHA256(Encrypt-then-MAC)
  • 保存形式:Base64( IV | Ciphertext | HMAC ) を1本の文字列として PlayerPrefs に保存
  • 鍵の扱い:サンプルでは簡易のため固定キーを使用。実運用では PBKDF2での鍵導出 や サーバ配布 等を検討
using System;
using System.Security.Cryptography;
using System.Text;

public static class Crypto
{
    // サンプル用の固定キー(32バイト = AES-256)。実運用では安全な管理を。
    // 例:PBKDF2でパスフレーズから導出、もしくはサーバ連携で配布など。
    static readonly byte[] AesKey  = Encoding.UTF8.GetBytes("0123456789abcdef0123456789abcdef"); // 32B
    static readonly byte[] HmacKey = Encoding.UTF8.GetBytes("fedcba9876543210fedcba9876543210"); // 32B

    public static string EncryptToBase64(string plaintext)
    {
        using var aes = Aes.Create();
        aes.Mode = CipherMode.CBC;
        aes.Padding = PaddingMode.PKCS7;
        aes.Key = AesKey;
        aes.GenerateIV(); // ランダムIV

        byte[] plainBytes = Encoding.UTF8.GetBytes(plaintext);

        using var enc = aes.CreateEncryptor();
        byte[] cipher = enc.TransformFinalBlock(plainBytes, 0, plainBytes.Length);

        // 連結: IV | CIPHER
        byte[] ivPlusCipher = new byte[aes.IV.Length + cipher.Length];
        Buffer.BlockCopy(aes.IV, 0, ivPlusCipher, 0, aes.IV.Length);
        Buffer.BlockCopy(cipher, 0, ivPlusCipher, aes.IV.Length, cipher.Length);

        // HMAC( IV | CIPHER )
        using var hmac = new HMACSHA256(HmacKey);
        byte[] tag = hmac.ComputeHash(ivPlusCipher);

        // 連結: IV | CIPHER | HMAC
        byte[] all = new byte[ivPlusCipher.Length + tag.Length];
        Buffer.BlockCopy(ivPlusCipher, 0, all, 0, ivPlusCipher.Length);
        Buffer.BlockCopy(tag, 0, all, ivPlusCipher.Length, tag.Length);

        return Convert.ToBase64String(all);
    }

    public static string DecryptFromBase64(string base64)
    {
        byte[] all = Convert.FromBase64String(base64);
        if (all.Length < 16 + 1 + 32) throw new Exception("データ長不正");

        // 末尾32BはHMAC
        int tagLen = 32;
        byte[] tag = new byte[tagLen];
        Buffer.BlockCopy(all, all.Length - tagLen, tag, 0, tagLen);

        // 先頭〜(末尾-32B)は IV|CIPHER
        int ivCipherLen = all.Length - tagLen;
        byte[] ivPlusCipher = new byte[ivCipherLen];
        Buffer.BlockCopy(all, 0, ivPlusCipher, 0, ivCipherLen);

        // HMAC検証
        using var hmac = new HMACSHA256(HmacKey);
        byte[] calc = hmac.ComputeHash(ivPlusCipher);
        if (!FixedTimeEquals(tag, calc)) throw new Exception("改ざん検知: HMAC不一致");

        // IV と Cipher に分割
        byte[] iv = new byte[16];
        Buffer.BlockCopy(ivPlusCipher, 0, iv, 0, 16);
        byte[] cipher = new byte[ivCipherLen - 16];
        Buffer.BlockCopy(ivPlusCipher, 16, cipher, 0, cipher.Length);

        using var aes = Aes.Create();
        aes.Mode = CipherMode.CBC;
        aes.Padding = PaddingMode.PKCS7;
        aes.Key = AesKey;
        aes.IV  = iv;

        using var dec = aes.CreateDecryptor();
        byte[] plain = dec.TransformFinalBlock(cipher, 0, cipher.Length);
        return Encoding.UTF8.GetString(plain);
    }

    static bool FixedTimeEquals(byte[] a, byte[] b)
    {
        if (a.Length != b.Length) return false;
        int diff = 0;
        for (int i = 0; i < a.Length; i++) diff |= a[i] ^ b[i];
        return diff == 0;
    }
}

3. 複数スロット × 暗号化つき PlayerPrefs 実装

using UnityEngine;

public static class SecurePrefJsonSlots
{
    const string SlotKeyPrefix = "SaveSlotEnc.";
    const string LastSlotKey   = "LastSaveSlotEnc";

    static string SlotKey(int slot) => $"{SlotKeyPrefix}{slot}";

    public static void SaveToSlot(int slot, GameData data, bool useEncryption = true)
    {
        if (data.level < 1 || 99 < data.level)
            throw new System.Exception("レベルは1〜99で指定してください");

        string json = JsonUtility.ToJson(data);

        string payload = useEncryption
            ? Crypto.EncryptToBase64(json)
            : json;

        PlayerPrefs.SetString(SlotKey(slot), payload);
        PlayerPrefs.SetInt(LastSlotKey, slot);
        PlayerPrefs.Save();

        Debug.Log($"[SecurePrefJsonSlots] Saved slot={slot}, enc={useEncryption}");
    }

    public static bool TryLoadSlot(int slot, out GameData data, bool useEncryption = true)
    {
        string key = SlotKey(slot);
        if (!PlayerPrefs.HasKey(key))
        {
            data = null;
            return false;
        }

        string payload = PlayerPrefs.GetString(key);
        string json = useEncryption
            ? Crypto.DecryptFromBase64(payload)
            : payload;

        data = JsonUtility.FromJson<GameData>(json);
        return data != null;
    }

    public static int GetLastSlotOrDefault(int defaultSlot = 1)
    {
        return PlayerPrefs.GetInt(LastSlotKey, defaultSlot);
    }

    public static void DeleteSlot(int slot)
    {
        PlayerPrefs.DeleteKey(SlotKey(slot));
        PlayerPrefs.Save();
    }
}

使い方(例)

// 保存(暗号化ON)
var data = new GameData { level = 20, score = 9876.5f, item = "賢者の杖" };
SecurePrefJsonSlots.SaveToSlot(1, data, useEncryption: true);

// 読み出し(暗号化ON)
if (SecurePrefJsonSlots.TryLoadSlot(1, out var loaded, useEncryption: true))
{
    Debug.Log($"[SECURE] slot1 → L{loaded.level}, S{loaded.score}, I:{loaded.item}");
}

4. スロット運用のTips

  • スロット一覧:固定数(例:1〜3)にしてメニューから選ばせる。
  • 表示名:保存時刻などを一緒にJSONへ格納して、メニューで表示。
  • 自動バックアップ:保存前に旧データを SaveSlotEnc.{slot}.bak などで別キーに待避。
  • サイズ上限:PlayerPrefsは大容量向けではありません(数KB〜数十KB目安)。長文化したらファイル保存へ移行。

5. セキュリティと運用の注意

  • 鍵管理:サンプルは固定キー。実運用は PBKDF2等でパスフレーズから鍵導出、あるいはサーバ配布を検討。
  • 完全防御ではない:クライアント内に鍵がある限りリバース解析の可能性は残る。サーバ検証やオンライン同期で補強。
  • 例外処理:復号失敗(HMAC不一致等)の際はセーフフォールバック(新規データ/復旧)を用意。
  • パフォーマンス:PlayerPrefsは書き込みでディスクI/Oが走るため、連打は避ける(バッファリング/保存間隔を調整)。

6. まとめ

  • 複数スロットは SaveSlot.{n} のようにキーを分ければ簡単に実現できる
  • 文字列の中身を JSON にすることで拡張が容易
  • さらに AES暗号化 + HMAC を加えると、最低限の覗き見・改ざん対策になる
  • データが成長したら JSONファイル保存 へ段階的に移行する設計が現実的

訪問数 3 回, 今日の訪問数 3回

C#,JSON,Unity

Posted by hidepon