Web謎の答えをどう隠すか、という話

no_?thumbnail no_?thumbnail

移転に伴ってかなりいろんな作業をしたので、そのついで。謎解きを作りたい人向けの話をいろいろしておこうかなと。

技術的な話なので、わからなければ直接聞いてもらえればと。あと、「ウチはこれでやってます」という話ではないです。あくまでこういう方法もあるよ、という参考程度に見てもらえれば。

そもそもWeb謎ってどういうふうにできてるのか

特にひねりの効いたことをしなくても、普通にhtml, js, cssがあればWeb謎は作れる。どんなに高度なギミックでも結局はこれらの合わせ技、というか力技でなんとかなる。前提知識は思ったより低い。

  • ルートディレクトリ
    • index.html
    • script.js
    • style.css

これが最低限の構成。

静的な部分(画面上の配置)はhtmlとcssで記述して、動的な部分(ボタンを押すと何が起こるとか、ユーザー側のアクションによること)はjavascriptで記述することになる。

【余談】typescriptは?

最近は typescript(TS) を javascript(JS) の代わりに使っている人が多い。

今から始めるならtypescriptでやりたいというのは大いに結構だが、ts で記述したファイルはビルドする時に js に変換されるので、実際にweb上で扱われるのは js になる。

script.jsに答えを直接書き込むと何がまずいのか

答えはシンプルで、ソースコードを見られてしまうと答えが筒抜けになってしまうから。

というかこのページを見に来た人の多くはそれを気にして見に来たんだと思うので、今さら説明するまでもないかもしれない。

難読化(可逆的暗号化)する

たとえば、文字コードを変えた状態で記述しておくなどの手がある。

const ANS = "%E3%81%9B%E3%81%84%E3%81%8B%E3%81%84";

console.log("true answer is: " + decodeURI(ANS));

これはただのやる気デストラクションなので、その気になれば技術的には簡単に解読できてしまう。ただソースコードを見るという悪知恵だけを付けた人に対しては対策になる。

非可逆的暗号化する

答えに非可逆的な暗号化を施してしまうというのは有力な手段。

const ANS_SHA256 = "470a643c75c00ab934b4f97d5af09927b5ea16e6907a48a59d0115b04b7e8238";

const hash = await crypto.subtle.digest("SHA-256", userInput);
//非同期処理なのでasyncスコープの中で扱うこと

if(ANS_SHA256 === hash){
  console.log("ok");
}

これだとソースコードを一目見ただけでは答えがわからないし、復号できないので解読されるおそれもない。

javascriptで気軽にやるならSHA-256MD5などの暗号化方式が使える(MD5は外部ライブラリが必要)。

ただし、「正解だった時に後続の処理をする」ということを考えた場合は不十分。後続の処理自体を暗号化することができない(復号できなくなってしまうので、後続の処理自体は生で記述せざるをえない)。この部分も見られたくないという場合には少し問題のあるメソッドではある。あくまで「ユーザーの入力が正解であるかどうか」の判定にしか使えない。逆に言うとそれしかしない場合はこの方法で十分。

また、遠い未来に改めてこのコードを見た時に自分でも理解できない可能性があることには注意。何を暗号化した文字列かはローカルで控えておくといい。

phpで記述する

html/jsは記述されている全ての処理をクライアントサイドで実行するが、phpはサーバーサイドで処理した結果を返す。

つまり、phpであれば答えを生で記述しても基本的には読み取られる恐れはない。

そこで、別途用意した正誤判定用のphpに回答データを送信し、判定とそれに付随する後続処理のデータを送り返してもらうという手段が取れる。

たとえば、https://my-domain.com/sample.phpに以下のような内容を記述したとする。

<?php
header("Content-Type: application/json; charset=utf-8");

$inputJSON = file_get_contents('php://input');
$inputData = json_decode($inputJSON, true);

if (!$inputData || !$inputData["input"]) {
  echo ("invalid access."); //適切なデータを受信していないとここで止められる
  exit;
}

//$inputDataの型安全性は確認していないので必要に応じてちゃんと書き足すのがよい
$judge = $inputData["input"] == "せいかい";

echo json_encode([
  "judge" => $judge
]);

このURIに対して、あなたのweb謎解きのスクリプトからデータを送信する。

    fetch("https://my-domain.com/sample.php", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ input: "せいかい" }),
    })
      .then((response) => {
        return nextAction(response.json()); //何らかの後続の処理
      })
      .catch((error) => {
        console.error("Fetch error:", error);
      });

そうすると、正誤判定をした上でデータを受信できる。

このURLを直接打ち込んでアクセスしようとしても、

invalid access.

データの送信元と中身が合っていないので以下のような画面しか出てこない。ソースも閲覧できない。

隠したい分岐についても、レスポンスに全て含めてしまうことで解決できる。正しい答えを入力した時しか後続の処理そのものが取得できない状態にしてしまえばよいということだ。

ひとつ気になる点があるとすれば使っているサーバーにphpをインストールしないといけないということだが、これに関してはwordpressが使えるサーバーを使うだけで解決する。そもそもwordpressはphpで動いているため、前準備なしでただphpファイルを配置するだけでこの方法が使える。見かけによらず割とイージーな手段であるということは覚えておいてよい。

phpの書き方を学ばないといけないというのは重く感じるが、近頃はやりたい内容を全部jsで書いてからChatGPTとかに懇切丁寧にお願いしながら投げつけるときれいなphpに書き直してくれちゃったりする。

それと、テストする環境によってはCORSポリシーについて理解し、正しい設定を行う必要がある。

【余談】CORSってなに?

Cross-Origin Resource Sharing、オリジン間リソース共有。異なるドメイン同士でデータのやり取りを行う権限を要求することをこう言う。詳しくはこれを読もう。

たとえば、さっきのスクリプトがhttps://my-domain.comになかったらどうなるか?

    fetch("https://my-domain.com/sample.php", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ input: "せいかい" }),
    })
      .then((response) => {
        return nextAction(response.json()); //何らかの後続の処理
      })
      .catch((error) => {
        console.error("Fetch error:", error);
      });

全く同じことを書いているのに、エラーになってしまうはずだ。これがCORSポリシーに引っかかった状態。これができたらあなたが書いたものでなくてもあらゆるリソースから情報を引っこ抜き放題なので、言語仕様で明確に禁止されている。

読み込まれる側で「○○からのリクエストは許可します」と明示的に設定されていれば、このエラーは回避できる。たとえばこんなふうに。

<?php
header("Access-Control-Allow-Origin: https://the-other-domain.com");
header("Access-Control-Allow-Headers: Authorization, Content-Type, X-Requested-With");
header("Access-Control-Allow-Methods: POST, OPTIONS");

Access-Control-Allow-Origin: *;と指定すると全オリジン許可設定になるのでテストには楽でいいかもしれないが、セキュリティが国会議事堂よりガバガバになるため推奨されない。

通信を扱うことになるとめちゃくちゃ躓くところだと思うので、あなたが有名になりすぎる前にこの辺の扱いに慣れておくといいと思う。

サーバーサイドjavascriptを使う

同じjavascriptを使えるからとこっちで対応したい気持ちはあるが、実はphpと敷居の高さは大差なく(むしろ高いとすら思う)、こちらはサーバーにphpの代わりにnode.jsをインストールする必要がある。しかもその上で常時走らせ続けていなければならない(daemon化する、と言う)。daemon化はpm2というアプリケーションを使うことで可能。

//依存関係のインポートが必要なのでこれだけでは全く動かない。
//何をしているかの参考程度に。

//https://my-domain.com/server/your-end-pointにアクセスするとこれが呼び出される
app.post("/your-end-point", async (req, res) => {
  console.log("get request: your-end-point"); 

  try {
    const { input } = req.body;

    if (!input) {
      return res.status(400).json({ error: "Invalid request data" });
    }
    if (input === "正解") {
      return res.json({ judge: true });
    } else {
      return res.status(400).json({ judge: false });
      });
    }
  } catch (error) {
    console.error("Fatal Error:", error);
    res.status(500);
  }
});

ただし、あなたが使っているのがルート権限のないレンタルサーバーの場合、サーバーの永続的な稼働は止められてしまう可能性が高い。ルート権限のあるサーバーは費用がそれなりにかかるため、Web謎を1本出したい、程度の目的だとするなら全く割に合わない。

そこを気にするのであればwordpressがインストールされている簡単なレンタルサーバーを借りて、phpで対応するのがお手軽。