feature image

2025年4月20日 | ブログ記事

VRChatワールド「クソでっけぇプッシャーゲーム」にHMAC‑SHA256署名付きクラウドセーブ機能を実装しました!【Udon#】

こんにちは、VRChatに住んでいるマスコットの、pikachu0310です!
今回はVRChatワールド「クソでっけぇプッシャーゲーム」に、データ改ざん防止機能(HMAC‑SHA256)付きのクラウドセーブを実装したので、その設計から実装までをわかりやすく解説します。


目次

  1. はじめに
  2. 全体構成
  3. データ改ざん検知(HMAC‑SHA256)とは
  4. Udon#クライアント実装
    1. 全体の流れ
    2. GETリクエスト送信の制約
    3. 自前 HMAC 実装
  5. サーバーサイド実装
    1. 全体の流れ
    2. OpenAPI定義
    3. データベース定義
    4. データ受送信
    5. データベース操作
  6. おわりに

はじめに

VRChat標準の「Persistence」機能(2024年11月実装)には、バグによるデータ破損報告がいくつか見られます。そこで、ワールド内部から直接信頼できるサーバーへ安全にデータ送受信し、バックアップや統計・ランキング機能も併せて実現できる独自クラウドセーブを作りました。

やったことの簡単なまとめ:

  1. Go + Echo で API サーバーを立ち上げ、MariaDB で永続化
  2. データ改ざん検知に HMAC‑SHA256 を Udon# で自前実装
  3. 署名付き 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 で送信。サーバー側でも同じ計算を行い、一致すれば「改ざんなし」と判断してデータを保存します。

本記事ではこの全体像と実装のポイントを順に解説し、関連コード全文へのリンクも掲載します。


全体構成

  1. Unity/Udon# クライアント (DataServerManager.cs)
    • パラメータを辞書順ソートし、HMAC‑SHA256 署名付き URL を生成
    • VRCUrlInputField に表示し、ユーザーがコピー&ペーストで送信
  2. API サーバー (Go + Echo)
    • openapi.yaml でインターフェース定義
    • HMAC 署名検証後に MariaDB へ保存/取得
  3. データベース (MariaDB)
    • プレイデータを格納
    • Goose マイグレーションでスキーマ管理

データ改ざん検知(HMAC‑SHA256)とは

HMAC (Hash-based Message Authentication Code) は、共通秘密鍵と SHA256 ハッシュを組み合わせ、データが途中で書き換えられていないかを検証する仕組みです。

  1. クライアントとサーバーで共有した グローバルシークレット を用意
  2. ユーザー ID をグローバルシークレットで HMAC し、ユーザー固有シークレット を生成
  3. 全パラメータを辞書順ソートして連結した文字列の HMAC を、ユーザー固有シークレットで計算
  4. URL に sig パラメータとして付与

サーバー側でも同じ手順で HMAC を再計算し、sig の値が一致すれば「改ざんなし」と判断します。


Udonクライアント実装

全体の流れ

コード全文はこちらで見れます。
DataServerManager.cs ひとつのスクリプトで実装しています。

  1. パラメータ配列をソート → クエリ文字列生成
  2. GenerateUserSignature で HMAC‑SHA256 署名生成
  3. 署名付き URL を urlSaveCopyText に出力
  4. ユーザーがコピー&ペーストで VRCUrlInputField にセット → VRCStringDownloader.LoadUrl で送信
  5. 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 で構築しました。
主な実装は以下のとおりです。

  1. OpenAPI 定義 (openapi/openapi.yaml)

    • GET /data → パラメータ受け取り+署名検証 → 保存
    • GET /users/{user_id}/data → 最新データ返却
    • GET /rankings → 上位50件返却
  2. DB マイグレーション (4_fix_version_to_int.sql)

    • game_data テーブル定義
    • id, user_id, 各種統計値, created_at
  3. ハンドラ (handler.go)

    • createSortedParamString で辞書順ソート→文字列化
    • generateUserSecret + verifySignature でHMAC検証
    • 検証成功後にリポジトリ経由で保存
  4. リポジトリ (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

クソでっけぇプッシャーゲーム

つい無限にやっちゃう激おもろワールド「クソでっけぇプッシャーゲーム」
pikachu icon
この記事を書いた人
pikachu

VRChatに住んでいる、重度のケモナーです。よろしくね!

この記事をシェア

このエントリーをはてなブックマークに追加
共有

関連する記事

2024年12月9日
ISUCON14「リアクティブ二子玉川~♪」32位(学生5位) 参加記
ikura-hamu icon ikura-hamu
2024年9月17日
第19回Game³開催しました!
wal icon wal
2024年7月1日
【NeoShowcase】traPには内製の作品公開プラットフォームがあります
toki icon toki
2024年3月24日
第18回Game³開催しました!
wal icon wal
2024年2月17日
第18回Game³開催のお知らせ
pirosiki icon pirosiki
2024年1月6日
traP ISUCON LTを開催しました
oribe icon oribe
記事一覧 タグ一覧 Google アナリティクスについて 特定商取引法に基づく表記