8
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【なんでも解説】オンライン麻雀「Maru-Jan」の牌操作防止システムに感じたこと

Posted at

こんばんは!
GMOコネクト 執行役員CTOな菅野 哲(かんの さとる)です。
「暗号っぽい話」が出てくると湧いてくる暗号おじさんです。

本記事は技術的な観点からの分析です。Maru-Janの取り組みを批判するものではありません。むしろ、このような透明性向上の試みがゲーム業界全体に広がることを期待しています。

はじめに

オンライン麻雀「Maru-Jan」を運営するシグナルトークが、2025年12月18日に「牌操作がないことの証明」システムを公開1しました。4Gamerでも同日に紹介2されています。

オンラインゲームにつきものの「運営が裏で操作してるんじゃないか」という疑念に対して、暗号技術の発想で"検証可能性"をユーザー側に寄せようとする、珍しくて面白い取り組みです。1

一方、セキュリティの観点で評価すると、危殆化したアルゴリズムの利用と、コミット(証拠)の固定化がユーザー行動に依存しているという課題も見えてきます。

本記事では、この仕組みを暗号プロトコル(コミットメント)の観点から整理し、「何が守れて、何が守れていないか」と「改善するとしたらどこから手を付けるべきか」を技術的に解説します。

システムの仕組み:暗号学的には「コミットメント」

公式の説明はざっくりこうです(重要なのは "手元に記録" のところ)。

  1. 対局開始時から、局の牌山データを変換したハッシュ値が表示できるので、手元に記録する
  2. 対局終了後、牌譜で牌山データを表示できるのでコピーする
  3. 牌山データを SHA-1 でハッシュ化し、(1)で記録したハッシュ値と一致すれば「開始時と終了後で変わっていない」1

暗号プロトコルの用語では、これは「コミットメント(commitment)」です。

  • 開始時に"元データ(牌山データ)"をハッシュ化して コミット(固定)
  • 後で"元データ"を提示して オープン(公開)
  • 観測者(ユーザ)がハッシュを取り直して 検証

という構造になっています。

コミットメントに求められる性質(ざっくり)

  • Hiding(秘匿性):コミット値(ハッシュ値)だけから元データを推測できない
  • Binding(拘束性):コミット後に"別の元データ"へすり替えられない

ここで地味に重要なのが、**「牌山データの表現(文字列化)が1通りに固定されていること」**です。
ハッシュは入力バイト列が1文字でも違うと全く別の値になるので、空白・改行・区切り・表記ゆれがあると“同じ牌山なのに不一致”という事故が起きます。したがって、データ形式の正規化(canonicalization)を仕様として固定するのが肝になります。
Maru-Janのプレスリリースでは、牌山データを 萬子=1m..9m、筒子=1p..9p、索子=1s..9s、字牌=1z..7z(東南西北白發中の順) で表し、赤牌は0m/0p/0sで表すといった表記ルールが明記されています。1
検証を成立させるには、この表記ルールに加えて、文字コード(例:UTF-8)・改行の有無・末尾スペース禁止なども含め、ハッシュ入力が“必ず同一の文字列”になるように取り決める必要があります。

セキュリティ評価:何が守れて、何が守れていないか

守れていること(前提付き):対局中の"後出しすり替え"の検知

この方式が狙う「牌操作」を、プレスリリースは "ゲーム中に牌山を意図的に変更すること" と説明しています。1

ここで重要な前提がひとつあります。

開始時点のハッシュ値(コミット値)が、ユーザー側で保持されていること(スクショ等)

この前提が満たされる限り、運営が対局中に牌山データを差し替えると、終局後に公開される牌山データのハッシュが一致しなくなり、後から検知されます。1
この意味で「コミットメントとしての発想」は筋が良いです。

課題1:危殆化したSHA-1の使用

手順に SHA-1 が明記されています。1
しかしSHA-1は衝突攻撃が現実に実証されており、NISTは 2030年12月31日までにSHA-1から移行する方針を明確にしています。3

この用途で「即、攻撃が成立するか?」は脅威モデル次第ですが、プロダクト設計としては次の通りです。

  • ✗ いま新規に採用するメリットがほぼない
  • ✗ 将来的な説明コスト(「なぜSHA-1?」)が確実に増える
  • ○ SHA-256 / SHA-3 への置き換えコストは低い(基本的に差し替え)3

暗号おじさんには、やはり 「変えない理由がない」 と思います。

課題2:コミットの固定化が"人間の記録"に依存している

より本質的な問題はここです。

公式手順は「開始時から表示できるので、手元に記録します」。1
でも現実には、人は毎局スクショを撮りません。撮っても検証までやりません。

つまり、この方式は

  • 理論上の検証可能性はある(やる気があればできる)
  • 実運用の監査性が弱い(人間の注意力に依存)
  • ✗ 「開始時に確かにこの値だった」がユーザー側に残らないと、後から検証できない

という性質になります。

ここを放置すると、「仕組みは正しいのに、誰も使わなくて信頼は上がらない」になりがちです。

課題3:牌山"選別"(対局前の恣意性)までは縛れない

この方式が縛るのは主に「対局中の変更」です。1
一方で、たとえば運営が(仮に悪意があるとして)「対局前に都合の良い牌山が出るまで生成し直して採用する」といった 選別(selection bias) は、この枠組みだけでは直接は防げません。

もちろん、Maru-Janがそれをしていると言っているわけではありません。
ただ「暗号で透明性を上げる」を突き詰めるなら、ここまで脅威モデルに入れると設計の方向性が変わります。

改善提案:暗号屋が考える「次の一手」

1. ハッシュアルゴリズムの更新

  • 現状:SHA-11
  • 推奨:SHA-256 または SHA-3-256(少なくともSHA-1は避ける)3

2. 「表示」ではなく「署名付きレシートを自動保存」

ユーザーに"スクショを取らせる"のではなく、開始時にクライアントが自動で「証拠」を保存できる形にすると、監査性が一気に上がります。
このデジタル署名は“受領後の改ざん検出”には効くのですが、一方でサーバがクライアントごとに異なる値を配る見せ分け(equivocation)まで潰すには、同卓内での相互照合や透明性ログが必要となります。

疑似コード

import canonicalize from "canonicalize"; // Javascript実装の例(使うなら依存を吟味)

// 対局開始時にサーバから受け取る前提(サーバが作って署名して返す)
const receipt = {
  v: 1,
  gameId: "game_12345",
  roundNumber: 1,

  // 何にコミットしているか(例:牌山データのハッシュ、または seed のハッシュ)
  commitmentHash: "base64url(...)",
  hashAlg: "SHA-256",

  // 仕様固定に必要なメタ情報
  format: "MJ-yama-v1",          // 牌山データの正規化仕様ID
  participants: ["A","B","C","D"], // 参加者ID
  order: ["A","B","C","D","S"],  // seed混合の順序固定

  // サーバ時刻(署名対象)
  serverTime: "2025-12-18T10:00:00Z",

  // 鍵の識別子(ローテのため)
  keyId: "mj-signing-key-2025-01"
};

// サーバは canonicalize(receipt) に対して署名して返す
const signedReceipt = {
  receipt,
  sigAlg: "Ed25519",
  signature: "base64url(...)"
};

// 保存は round ごとに
const key = `commitment_${signedReceipt.receipt.gameId}_${signedReceipt.receipt.roundNumber}`;
localStorage.setItem(key, JSON.stringify(signedReceipt));

これにより:

  • ✓ ユーザーの手動記録が不要
  • ✓ "開始時点のコミット"が端末内に残る
  • ✓ 後から「表示を書き換えた」類の疑念に強くなる(署名検証で弾ける)
  • ✓ 仕様バージョン管理により将来の変更にも対応

※注意:ローカル保存は「消える/消せる」ので、強くしたいなら後述のログ化や、複数端末・第三者保管へ。

3. Transparency Log(Merkle Treeログ)で"あとから整合性監査"を可能に

さらに強固にするなら、Certificate Transparency(CT)と同じ発想で append-onlyログ(Merkle Tree)に記録し、「過去を消した・書き換えた」を検知可能にします。RFCとしてはCT v2(RFC 9162)が存在し、RFC 6962(CT 1.0)はobsoleted扱いです。4

ただし重要な注意点が2つあります:

  • Merkle Treeを使ったログ=改ざんが"不可能"ではない
    → "改ざんが検知可能"を強くする仕組みです
  • ログ運営者がユーザーごとに異なる木(STH)を見せる split-view(見せ分け) は理屈上あり得る
    → これを抑止/検知するのが gossip / witness の世界です5

プロトコルフロー

Phase 1: コミットメントのログ追加
1. サーバがコミットメントレシート receipt を生成
2. entry = {
     timestamp: "2025-12-18T10:00:00.123Z",
     leafData: canonicalize(receipt)
   }
3. Merkle Tree にリーフとして追加
   leafHash = SHA-256("MJ-MTL" || 0x00 || canonicalize(entry))
   ※ 0x00 は leaf ノードのマーカー

Phase 2: STH (Signed Tree Head) の発行
定期的(例:10分ごと)に以下を発行:
STH = {
  v: 1,
  treeSize: 12345,              // 現在のリーフ数
  timestamp: "2025-12-18T10:10:00Z",
  rootHash: "base64url(...)",   // Merkle Tree Root
  keyId: "mj-log-key-2025-01"
}
署名対象 = canonicalize(STH)
signedSTH = { STH, sigAlg: "Ed25519", signature: "..." }

Phase 3: 検証(クライアント側)
1. 対局開始時の receipt をローカル保存
2. 定期的に最新 STH を取得
3. サーバに Audit Proof を要求:
   GET /log/audit-proof?tree-size={treeSize}&leaf-hash={leafHash}
   
   Response:
   {
     leafIndex: 5678,
     treeSize: 12345,
     auditPath: [
       "base64url(hash_1)",
       "base64url(hash_2)",
       ...
     ]
   }

4. Audit Path を用いて Root Hash を再計算:
   currentHash = leafHash
   for (i, siblingHash) in enumerate(auditPath):
       if (leafIndex >> i) & 1 == 0:
           # 左の子
           currentHash = SHA-256("MJ-MTL" || 0x01 || currentHash || siblingHash)
       else:
           # 右の子
           currentHash = SHA-256("MJ-MTL" || 0x01 || siblingHash || currentHash)
   
   assert currentHash == STH.rootHash

5. STH の署名を検証

ドメイン分離の詳細

# Leaf hash(リーフノード)
domain_leaf = b"MJ-MTL"  # Maru-Jan Merkle Tree Leaf
marker_leaf = b"\x00"
leaf_hash = SHA256(domain_leaf + marker_leaf + canonicalize(entry))

# Internal node hash(内部ノード)
domain_node = b"MJ-MTL"
marker_node = b"\x01"
node_hash = SHA256(domain_node + marker_node + left_hash + right_hash)

さらなる強化:Gossip Protocol

複数の独立した監視ノード(Witness)間で STH を交換:

┌─────────┐     ┌─────────┐     ┌─────────┐
│Witness A│────→│Witness B│────→│Witness C│
└─────────┘     └─────────┘     └─────────┘
     ↑               ↑               ↑
     └───────────────┴───────────────┘
            STH の相互検証

異なる STH が見えた場合 → Split-view 検出 → アラート

4. Commit-Revealによる乱数生成(牌山"選別"まで防ぎたい場合)

「対局中の改ざん」だけでなく、「対局前の牌山選別」まで抑えたい場合は、プレイヤーも乱数生成に参加させるのが王道です。

フェーズ1: Commit
各参加者 i は 256-bit 乱数 ri を生成
commit_i = SHA-256("MJ-commit" || game_id || round_id || participant_id_i || ri)
commit_i を全員に配布(クライアントは自動保存)

フェーズ2: Reveal
全員が ri を公開
各クライアントは SHA-256(...) が commit_i と一致することを検証
(不一致/未提出は規定に従い失格等)
(reveal拒否(abort)にどうペナルティを課すかが「ゲーム設計」上のキモになりそうですね!)

フェーズ3: 牌山生成
seed = SHA-256("MJ-seed" || game_id || round_id || r_1 || r_2 || ... || r_n)
(r_i の順序は participant_id の昇順などで固定)
seed から決定論的シャッフルで牌山を生成

これにより:

  • 運営単独で結果をコントロールしづらくなる(少なくとも1人が正しく乱数を出せば偏らない)
  • ✓ プレイヤーが生成プロセスに(暗号学的に)参加できる
  • ✓ "対局前の選別"耐性が上がる

まとめ:面白い挑戦。次は「人間依存を外す」と強くなる

Maru-Janの取り組みは、「疑念」に対して"検証可能性"を提示した点でとても価値があります。1
一方で、セキュリティの観点では次が課題です。

  • SHA-1を使っている(説明責任と将来負債が重い)1
  • コミットの固定化がユーザーのスクショに依存している(実運用で監査性が落ちる)1
  • (より強くしたいなら)対局前の選別まで含めた脅威モデルを検討できる

改善の優先順位としては、

  1. SHA-256/SHA-3へ移行
  2. 署名付きレシートの自動保存
  3. 必要に応じてログ化(Merkle Tree + gossip/witness)
  4. さらに踏み込むなら commit-reveal で生成の公平性まで

が「実装コストの観点からもリーズナブルな」ルートだと個人的に思います。


最後に、GMOコネクトでは研究開発や国際標準化に関する支援や技術検証をはじめ、幅広い支援を行っておりますので、何かありましたらお気軽にお問合せください。

お問合せ: https://gmo-connect.jp/contactus/

  1. オンライン麻雀「Maru-Jan」牌操作がないことの証明を公開 | シグナルトーク 2 3 4 5 6 7 8 9 10 11 12 13

  2. 長寿オンライン麻雀「Maru-Jan」,「牌操作がないことの証明」を公開 - 4Gamer.net

  3. NIST Transitioning Away from SHA-1 for All Applications | CSRC 2 3

  4. RFC 9162 - Certificate Transparency Version 2.0

  5. Gossiping in CT (draft-ietf-trans-gossip)

8
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?