こんにちは、VRChatに住んでいるマスコットの、pikachu0310です!
今回はVRChatワールド「クソでっけぇプッシャーゲーム」に、データ改ざん防止機能(HMAC‑SHA256)付きのクラウドセーブを実装したので、その設計から実装までをわかりやすく解説します。
- Save Data URL をコピーして、右側の入力欄にペーストするとユーザーデータをクラウドにセーブできる。
- Load Data URL をコピーして、右側の入力欄にペーストするとユーザーデータをクラウドにロードできる。
目次
- はじめに
- 全体構成
- データ改ざん検知(HMAC‑SHA256)とは
- Udon#クライアント実装
- サーバーサイド実装
- 全体の流れ
- OpenAPI定義
- データベース定義
- データ受送信
- データベース操作
- おわりに
はじめに
VRChat標準の「Persistence」機能(2024年11月実装)には、バグによるデータ破損報告がいくつか見られます。そこで、ワールド内部から直接信頼できるサーバーへ安全にデータ送受信し、バックアップや統計・ランキング機能も併せて実現できる独自クラウドセーブを作りました。
やったことの簡単なまとめ:
- Go + Echo で API サーバーを立ち上げ、MariaDB で永続化
- データ改ざん検知に HMAC‑SHA256 を Udon# で自前実装
- 署名付き URL を生成し、外部へ GET 送受信する Unity クライアントを実装
例えば、以下のように全パラメータを辞書順に並べた文字列――
fever=0&get_shirbe=84&have_medal=114514&in_medal=1100&medal_1=55&medal_2=13&medal_3=2&medal_4=0&medal_5=1&out_medal=20041&R_medal=78&shirbe_buy300=5&slot_hit=210&start_slot=583&total_play_time=33988&user_id=[1] Local Player&version=1
――をもとに計算した HMAC‑SHA256 署名(例: sig=c54eaac2b5ced0de401864c75f4eaa2e4240c293f17742e198d16dc6b92998cc
)を付与した URL で送信。サーバー側でも同じ計算を行い、一致すれば「改ざんなし」と判断してデータを保存します。
本記事ではこの全体像と実装のポイントを順に解説し、関連コード全文へのリンクも掲載します。
全体構成
- Unity/Udon# クライアント (
DataServerManager.cs
)- パラメータを辞書順ソートし、HMAC‑SHA256 署名付き URL を生成
VRCUrlInputField
に表示し、ユーザーがコピー&ペーストで送信
- API サーバー (Go + Echo)
openapi.yaml
でインターフェース定義- HMAC 署名検証後に MariaDB へ保存/取得
- データベース (MariaDB)
- プレイデータを格納
- Goose マイグレーションでスキーマ管理
データ改ざん検知(HMAC‑SHA256)とは
HMAC (Hash-based Message Authentication Code) は、共通秘密鍵と SHA256 ハッシュを組み合わせ、データが途中で書き換えられていないかを検証する仕組みです。
- クライアントとサーバーで共有した グローバルシークレット を用意
- ユーザー ID をグローバルシークレットで HMAC し、ユーザー固有シークレット を生成
- 全パラメータを辞書順ソートして連結した文字列の HMAC を、ユーザー固有シークレットで計算
- URL に
sig
パラメータとして付与
サーバー側でも同じ手順で HMAC を再計算し、sig
の値が一致すれば「改ざんなし」と判断します。
Udonクライアント実装
全体の流れ
コード全文はこちらで見れます。
DataServerManager.cs
ひとつのスクリプトで実装しています。
- パラメータ配列をソート → クエリ文字列生成
GenerateUserSignature
で HMAC‑SHA256 署名生成- 署名付き URL を
urlSaveCopyText
に出力 - ユーザーがコピー&ペーストで
VRCUrlInputField
にセット →VRCStringDownloader.LoadUrl
で送信 OnStringLoadSuccess
/OnStringLoadError
で結果を受信し、VRCJson
でパース
GETリクエスト送信の制約
VRChat の Udon# では HTTP ヘッダや POST が利用できず、GET のみかつ**VRCStringDownloader.LoadUrl
経由**でしか外部通信ができません。
そのため、ユーザーがコピー&ペーストで URL を送信するフローが必須になります。
参照: https://scrapbox.io/i544c/Udonで外部にデータを保存する
自前HMAC実装
System.Security.Cryptography
が使えないため、有志作成の vrchat-udon-hashlib を利用し、HMAC-SHA256 部分を自前で実装しました。
以下は署名生成の主要部分です。
// 簡易 UTF-8 変換 (ASCII 範囲のみ)
private byte[] ToUtf8(string str) {
var b = new byte[str.Length];
for (int i = 0; i < str.Length; i++) b[i] = (byte)str[i];
return b;
}
// 署名生成メイン関数
private string GenerateUserSignature(string userId, string queryString) {
// STEP1: グローバル→ユーザー固有シークレット
string userSecretHex = HmacSha256(GlobalSecret, userId);
byte[] userSecret = HexToBytes(userSecretHex);
// STEP2: パラメータ文字列をユーザーシークレットで署名
return HmacSha256_Bytes(userSecret, queryString);
}
// byte[] キー対応 HMAC-SHA256 (ipad/opad 実装)
public string HmacSha256_Bytes(byte[] key, string message) {
const int BlockSize = 64;
byte[] msg = ToUtf8(message);
// (1) key 長 > 64B: SHA256 で短縮
if (key.Length > BlockSize) {
key = HexToBytes(hashLibrary.SHA256_Bytes(key));
}
// (2) 64B パディング
var k = new byte[BlockSize]; key.CopyTo(k, 0);
// (3) ipad/opad 生成
var ipad = new byte[BlockSize];
var opad = new byte[BlockSize];
for (int i = 0; i < BlockSize; i++) {
ipad[i] = (byte)(k[i] ^ 0x36);
opad[i] = (byte)(k[i] ^ 0x5c);
}
// (4) SHA256(ipad||msg)
var innerHash = HexToBytes(hashLibrary.SHA256_Bytes(Concat(ipad, msg)));
// (5) SHA256(opad||innerHash)
return hashLibrary.SHA256_Bytes(Concat(opad, innerHash));
}
サーバーサイド実装
全体の流れ(サーバー)
コード全体はこちらで見れます。
自作サーバーテンプレートを活用し、Go + Echo + MariaDB で構築しました。
主な実装は以下のとおりです。
-
OpenAPI 定義 (
openapi/openapi.yaml
)- GET
/data
→ パラメータ受け取り+署名検証 → 保存 - GET
/users/{user_id}/data
→ 最新データ返却 - GET
/rankings
→ 上位50件返却
- GET
-
DB マイグレーション (
4_fix_version_to_int.sql
)game_data
テーブル定義id
,user_id
, 各種統計値,created_at
-
ハンドラ (
handler.go
)createSortedParamString
で辞書順ソート→文字列化generateUserSecret
+verifySignature
でHMAC検証- 検証成功後にリポジトリ経由で保存
-
リポジトリ (
data.go
)InsertGameData
/GetUserGameData
/GetRankings
グローバルシークレットは環境変数で管理しています。
OpenAPI定義 (openapi/openapi.yaml
)
SwaggerHubで見れます。
openapi: 3.0.3
info:
title: Very Big Medal Pusher Game Data API
version: "1.0.1"
description: |
"クソでっけぇプッシャーゲーム" のクラウドセーブ用API。
servers:
- url: https://push.trap.games/api
paths:
/data:
get:
summary: ゲームデータ保存
parameters:
- $ref: "#/components/parameters/version"
- $ref: "#/components/parameters/user_id"
# ...have_medal など... (省略)
- $ref: "#/components/parameters/sig"
responses:
'200': description: 保存成功
'400': description: 署名不正 or パラメータ不足
'500': description: サーバーエラー
/users/{user_id}/data:
get: ユーザーデータ取得
/rankings:
get: ランキング取得
components:
schemas:
GameData: type: object
# version/user_id/have_medal…の定義 (省略)
データベース定義 (internal/migration/4_fix_version_to_int.sql
)
-- +goose Up
DROP TABLE IF EXISTS game_data;
CREATE TABLE game_data (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id VARCHAR(255) NOT NULL,
version INT NOT NULL,
have_medal INT NOT NULL DEFAULT 0,
-- … (省略)
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- +goose Down
DROP TABLE IF EXISTS game_data;
データ受送信 (internal/handler/handler.go
)
package handler
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"net/http"
"sort"
"strings"
"github.com/labstack/echo/v4"
"…/models"
)
var GlobalSecret = "your_global_secret_here"
func (h *Handler) GetData(c echo.Context, params models.GetDataParams) error {
// (1) ソートして文字列化
dataStr := createSortedParamString(params)
// (2) ユーザー固有シークレット生成
userSecret := generateUserSecret(params.UserId)
// (3) 署名検証
if !verifySignature(dataStr, params.Sig, userSecret) {
return c.JSON(http.StatusBadRequest, "invalid signature")
}
// (4) DB保存
if err := h.repo.InsertGameData(c.Request().Context(), domain.GetDataParamsToGameData(params)); err != nil {
return c.JSON(http.StatusInternalServerError, err.Error())
}
return c.JSON(http.StatusOK, "success")
}
// 署名検証
func verifySignature(data, sig string, secret []byte) bool {
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(data))
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(sig))
}
データベース操作 (internal/repository/data.go
)
func (r *Repository) InsertGameData(ctx context.Context, data models.GameData) error {
_, err := r.db.ExecContext(ctx, `
INSERT INTO game_data (
user_id, version, have_medal, …, fever
) VALUES (?, ?, ?, …, ?)
`, data.UserId, data.Version, data.HaveMedal, …, data.Fever)
return err
}
おわりに
Udon# の制約下でも HMAC‑SHA256 による改ざん防止を組み込み、安心して使えるクラウドセーブ機能を実装できました!
本機能は既にワールドに展開されています。
作った感想は、サーバーの構築よりもUdon#の制約下で署名の部分を実装したりする方がだいぶ大変でした。通話しながらワイワイ作ってたのですが、全体で2日間もかかっちゃいました。(でも楽しかった!)
ご質問やフィードバックは Twitter の DM か、ココのコメントでお気軽にお寄せください~~!(待ってるぜ!)
データが壊れちゃった~>< なども対応できるので、ぜひご気軽に連絡してね~!
それでは、つい無限にやっちゃう激おもろワールド「クソでっけぇプッシャーゲーム」みんなもやってみてね!!!
Udon#の実装: https://github.com/pikachu0310/VRCWorld-CloudSaveSample
サーバーの実装: https://github.com/pikachu0310/very-big-medal-pusher-data-server
ワールドリンク: https://vrchat.com/home/world/wrld_1af53798-92a3-4c3f-99ae-a7c42ec6084d/info
クソでっけぇプッシャーゲーム
