チカラの技術

電子工作やプログラミング

【VRChat】UdonJwtの使い方【改ざん対策】

本記事はVRChatで利用できるJwtライブラリの「UdonJwt」の解説記事になります。

想定読者:Downloading系APIを利用するギミックを制作していてローカルの改ざんを防止したい人。

本記事は導入方法の解説がメインですのでU#とJWTについての基礎知識は前提となりますのでご了解ください。
(ハッキリ言って普通のU#ギミックより難しいです。ギミックの特性上必要な構造なので・・・)

概要

UdonJwtは公開鍵署名検証を実現するU#ライブラリです。
作者はkoyashiroさん (koyashiro (@koyashiro) / Twitter)。

主な仕様:

  • 公開鍵でトークンの署名を検証します。秘密鍵で署名する機能は実装されていません。
  • 署名アルゴリズムはRS256 [鍵長2048bit] のみサポート。他のアルゴリズムの指定はエラーとなります。
  • 公開鍵はpem形式の文字列を受け付けます。
  • トークンに'exp' payloadを持つ場合、VRChatのサーバー時間APIを使用してトークンの有効期限を検証します。
  • 現在のバージョンはV0.1.5

改ざん対策としてUdonJwtを利用する場合のシステム構成図を示します。


用途例: Donwloading系APIのデータ改ざんを拒否できる。(現在、中間者攻撃が確認されています。詳細は前回の記事を参照ください。)
注意点: 現状のUdonの処理速度が遅いため、署名検証処理に数秒掛かる。データが大きい場合はハッシュ化して利用するなど工夫が必要。
     (図ではトークンにハッシュを入れてデータと分けて送っている)

利用するクラス

次章の導入解説と合わせて見ると理解が容易になると思います。

JwtRS256Decorder

JWTの署名検証を実行します。事前にUnityのinspector上でpublic keyを設定しておきます。 ユーザーが使用するメソッドは一つだけです。

 void Decode(string token, JwtDecorderCallback callback)

第一引数はJWTトークンの文字列、第二引数は後述のJwtDecorderCallbackのインスタンスです。

JwtDecorderCallback

JWTの署名検証結果を受け取るための抽象クラスです。UdonSharpBehaviourを継承しています。
ユーザーは結果を受け取るクラスをこの抽象クラスで継承してそのインスタンスをJwtRS256DecorderのDecode()に渡します。
(U#でメソッドの実行側呼び出しを実現するときにActionやFuncが利用できなためこのような実装になっています)

抽象メソッド

OnProgress検証の実行中に進行状況を報告するため呼ばれます。Progressプロパティで進捗状況をチェックします。
OnEnd検証完了時に呼ばれます。検証の成否はResultプロパティ、エラー原因はErrorKindプロパティでチェックします。


プロパティ

Progress型:float検証の進行度を0.0~1.0の範囲で示します。
Result型:bool検証の結果を示します。
ErrorKind型:JwtDecodeErrorKind (enum) 検証失敗時の要因を示します。
Header型:UdonJsonValue取り出したトークンのHeaderを示します。
Payload型:UdonJsonValue取り出したトークンのPayloadを示します。

導入解説

~基本編~ Payloadに直接データを載せる

① データ発行者側の環境で鍵生成ライブラリを利用して秘密鍵と公開鍵、Jwtライブラリを利用してトークンを発行しておきます。(例:Node.jsのjsonwebtoken。秘密鍵と公開鍵の生成はopenssl)
 ここではトークンのpayloadに"data"claimとして"this is something data"という文字列を設定します。

② UdonJwtのReleaseからパッケージをダウンロードしてUnityプロジェクトにインポートします。
 (ただ現在はインストール方法の過渡期で今後はvpmで進めていくとのことです。)

github.com




③ Unity上でUdonJwtを利用するU#コンポーネントを作成します。
ここではJwtTestとします。

④ 同GameObjectにJwtRS256Decorderコンポーネントを追加します。
(Add componentで検索窓にjwtと打つと出てきます)

⑤ JwtRS256Decorderに①で生成した公開鍵を入力します。

⑥ JwtTestの継承をUdonSharpBehaviour → JwtDecorderCallbackに変更します。
  抽象メソッドも実装します。

public class JwtTest : JwtDecorderCallback
{
    void Start()
    {
    }

    public override void OnProgress()
    {
    }

    public override void OnEnd()
    {
    }
}

⑦ jwt Decorderを取得してメソッドDecodeを呼んで署名検証を開始します。
 第一引数はトークン文字列、第二引数は自身のインスタンスです。

public class JwtTest : JwtDecorderCallback
{
    public string jwtToken;
    private JwtRS256Decoder _jwtRS256Decoder;

    void Start()
    {
         _jwtRS256Decoder = this.GetComponent<JwtRS256Decoder>();
         _jwtRS256Decoder.Decode(jwtToken, this);
    }

    public override void OnProgress()
    {
    }

    public override void OnEnd()
    {
    }
}

⑧ 署名検証の進行時にOnProgress()が呼ばれます(20回程度)。進行状況は継承したProgress変数に0~1.0の間で示されます。

    public override void OnProgress()
    {
            Debug.Log("JWT Progress: " + (int)(Progress * 100) + "[%]");
    }

⑨ 署名検証の完了時にOnEnd()が呼ばれます。Resultプロパティがtrueなら検証成功です。
 falseの場合はErrorKindプロパティでエラー要因をチェックして検証を終了してください。
 検証成功時、トークンのpayloadはPayloadプロパティに収められています。claim名から取得、型をチェックしてから取り出してください。

        public override void OnEnd()
        {
            if (!Result)
            {
                var errorNames = new string[] { "None", "Busy", "InvalidToken", "InvalidSignature", "ExpiredToken", "Other" };
                var error = errorNames[(int)ErrorKind];
                Debug.Log("JWT decode error:  :" + error);
                return;
            }
            // -----------------------Decode is successed-----------------
            // Get data by token
            if (!Payload.TryGetValue("data", out var data)) return;
            if (data.GetKind() != UdonJsonValueKind.String) return;
            var dataString = data.AsString();
            Debug.Log(dataString );    // "this is something data"
         }

 ※ UdonJsonValue型の詳細についてはUdonJsonを参照してください。
GitHub - koyashiro/udon-json: JSON serializer/deserializer for UdonSharp.

以上で検証してpayloadを取り出せました。

~応用編~ Payloadにデータのハッシュのみ載せる

冒頭注意点の通り、payloadのサイズが数百バイト程度であればデータを直接payloadに載せても問題有りませんが、数キロバイト以上になってくると検証時間が無視できなくなるため対策が必要です。
図で示します。


まず大きなデータですが発行側でBASE64化してデリミタ(例えば'&')でトークンとつないで送信します。
U#でTextDownloadingにて受信したのちstring.Split('&')でBase64Dataとして分離、 Convert.FromBase64String(Base64Data)でbyteに戻します。

以下のメソッド(CheckDataByHash)で署名検証成功後にハッシュをチェックします。
引数としてPayloadプロパティとtextBytes(前述のbyte型データ)を渡しています。
トークンのpayload "dataHash" claim(ハッシュ文字列)をbyte化、textBytes(前述のbyte[]型データ)のハッシュと比較しています。

        private const int SHA256_HASH_SIZE = 32;

        /// <summary>
        /// Check Hash is match
        /// </summary>
        /// <param name="payloadJson"></param>
        /// <returns></returns>
        private bool CheckDataByHash(UdonJsonValue payloadJson, byte[] textBytes)
        {
            // Get dataHash by token
            if (!payloadJson.TryGetValue("dataHash", out var dataHash)) return false;
            if (dataHash.GetKind() != UdonJsonValueKind.String) return false;
            var dataHashByTokenString = dataHash.AsString();

            var dataHashByToken = new byte[SHA256_HASH_SIZE];
            for (int i = 0; i < dataHashByTokenString.Length / 2; i++)
            {
                dataHashByToken[i] = Convert.ToByte(dataHashByTokenString.Substring(i * 2, 2), 16);
            }

            var dataHashByRaw = SHA256.ComputeHash(textBytes);

            return CompareSHA256hash(dataHashByToken, dataHashByRaw);
        }

        /// <summary>
        /// compare two byte hash to same or not.
        /// </summary>
        /// <param name="hash1"></param>
        /// <param name="hash2"></param>
        /// <returns></returns>
        private bool CompareSHA256hash(byte[] hash1, byte[] hash2)
        {
            for(int i = 0; i < SHA256_HASH_SIZE ; i++)
            {
                   if (hash1[i] != hash2[i]) return false;
            }
            return true;
        }

CheckDataByHash()の結果がtrueならば、大きなデータの検証は成功です。

UdonJwtの実装例

以下は私がUdonJwtを実装したアプリケーション例です。
無料で配布していますので参考になればと思います。

① 本記事の導入~基本編~のように、payloadにVRChatを直接載せています。(ハッシュ化していますが、難読化目的です。)
  トークン発行側はwindowsアプリです。
  JWT Locker【パスワードギミック】 - チカラの技術 - BOOTH

② 本記事の導入~応用編~のように、payloadに数kバイトのデータをハッシュ化したものを載せています。
  トークン発行側はnode.jsです。
  Udon Auto Lock [自動開錠式鍵ギミック] - チカラの技術 - BOOTH

まとめ

UdonJwtの導入方法と応用を述べました。改ざんにお悩みの方、是非使ってみてくださいね!

参考文献

① 【VRChat】Discordメンバーのみ自動開錠する鍵ギミックを作りました【無料配布】 - チカラの技術(前回記事、中間者攻撃の解説など)

② 【VRChat】リッピングでパスワードギミックが破られるので公開鍵を利用した対策品を作りました【無料配布】 - チカラの技術(前々回記事、UdonJwtの製作経緯など)