端末に依存しない「ほぼ」正確な時刻を取得する

に公開
3

某対魔忍アプリにおいて、端末の時間を弄ることで過去や未来のイベントを遊ぶことが出来る問題が起きました。

本投稿では「NTP を使わず」「HTTPS 接続で」ある程度信頼のおける時間を取得する方法を試します。

NTP の場合、例えば DNS 解決時 ntp.nict.jp 等のサーバー名が自分の端末の IP を指すようにすれば割と簡単に偽装できる(らしい?)

「ある程度信頼のおける時間」の取得方法

安全な通信を確立する方法が限られているため、今回は時刻の取得に NTP を使いません。

代わりに HTTPS 接続のレスポンスに含まれる Date ヘッダーの時刻を使うことで端末に依存しない時間の取得を行います。簡単ですね!

追記)Roughtime

約10年ほど前に今回の投稿と同じコンセプト「時刻の正確さより内容の信頼性」を求めたプロトコルを Google が開発していたようです。ただ、活発に更新されているようには見えません。

https://github.com/cloudflare/roughtime

https://developers.cloudflare.com/time-services/roughtime/

(この分野では Cloudflare をよく見かけますね)

インフラならともかくアプリ層で使うにはちょっとゴツいプロトコルです。

HTTPS のレスポンスの時刻「だけ」おかしくなるという事は基本的に無いと思います。インフラ側の時刻がズレているならば、それどころではない重大なインシデントが起きている状態なのでメンテ突入待ったなしです。

アプリレベルでは気にしなくて大丈夫なので HTTPS レスポンスの Date ヘッダーを使っておけば十分でしょう。

Unity 問題

残念なことに Unity では HttpClientHandlerServerCertificateCustomValidationCallback を設定して HttpClient に渡しても反応しません。エラーすら起きません。

加えて UnityWebRequestcertificateHandler も TLS のバージョンが新しいと上手く動かないようです。(なんか Curl のエラーが出る)

※ Unity 2021 LTS SslStream では動くので凡ミスの可能性アリ

打つ手がありません。

SslStream を使う

しょうがないので生でリクエストします。大まかな流れは以下の通り。

  • サーバーに繋ぐ
  • AuthenticateAsClientAsync する
    • 証明書の検証を行う
  • $"HEAD / HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\n\r\n" を送る
  • レスポンスから Date ヘッダーを取得し解析する
  • ✨ 解析した日付を使ってカスタム日付オブジェクトを作る ✨

カスタム日付オブジェクト

都度サーバーに問い合わせを行っていては時間がかかりますし、自前のサーバーの場合は負荷も金もかかります。

なので、一度インスタンス化してしまえば「ある程度正確な時間」を何回でも取得出来るようにします。

readonly DateTimeOffset origin;  // HTTPS レスポンスから取得した「ある程度正確な時間」
readonly long timestamp;         // レスポンスを受けた時間のタイムスタンプ(ローカルPC)

NetworkClock(DateTimeOffset origin)
{
    this.origin = origin;
    this.timestamp = Stopwatch.GetTimestamp();
}

// 起点となる時間にローカル PC での経過時間を足すだけ!
public DateTimeOffset Now => origin.Add(GetElapsedTime(this.timestamp));

あとは AI とペアプロするだけですね!

NetworkClock

ペアプロの末、出来上がったのがコチラ。

https://github.com/sator-imaging/Unity-Fundamentals/blob/main/Runtime/System/NetworkClock.cs

使用例(レスポンスに時刻が含まれていれば良いのでパブリック DNS を使う)

m_Clock = NetworkClock.Create(
    "1.1.1.1",
    TimeSpan.FromHours(9),  // Asia/Tokyo
    (message, cert, chain, errors) =>
    {
        X509Certificate2? rootCert = null;

        foreach (var elem in chain.ChainElements)
        {
            rootCert = elem.Certificate;
        }

        if (rootCert != null)
        {
            using var store = new X509Store(StoreName.Root, StoreLocation.CurrentUser);
            store.Open(OpenFlags.ReadOnly);

            foreach (var r in store.Certificates)
            {
                if (r.Thumbprint == rootCert.Thumbprint &&
                    rootCert.Issuer.Contains(", O=DigiCert Inc, ", StringComparison.Ordinal))
                {
                    return errors == SslPolicyErrors.None;
                }
            }
        }

        return false;
    });

var now = m_Clock.Now;
var utc = m_Clock.UtcNow;

※ 証明書の Issuer の確認は多分意味ないです。

NTP ”も” 使う

NTP はネットワーク遅延を考慮したプロトコルになっています。

  • 送信時間を添えてサーバーにリクエストを送る
  • サーバーは以下のタイムスタンプを返す
    1. クライアントから送られてきた送信時間のタイムスタンプ
    2. リクエスト受信時間のタイムスタンプ(クライアント→サーバー間の遅延を測定)
    3. サーバーから時刻を送信する直前のタイムスタンプ(リクエストの処理時間を測定)
  • クライアントは受け取った各タイムスタンプからネットワーク遅延やレスポンス時間を考慮して時刻を設定

※ NTP クライアントを適切に実装していない場合、HTTPS の Date ヘッダーを使った「ある程度正確な時間」と大差ないと思います。

NetworkClock と組み合わせて使う

NTS(Network Time Security)というセキュアな通信を行う後継プロトコルもあるようですが、あまり情報がありません。面倒だったので調べるのは途中で止めました。

C# の NTS(Network Time Security)の最小実装コード(AI談)

もちろん!C#でNTS(Network Time Security)を利用する最小実装例は以下の通りです。まず、タイムサーバーへの安全な接続を行う設定を示すコードです。実際の動作にはライブラリや外部の設定が必要です。

using System;
using System.Net;
using System.Net.Security;

class Program
{
    static void Main()
    {
        // サーバーと安全に接続する準備
        var client = new WebClient();
        client.Credentials = CredentialCache.DefaultCredentials;

        // サーバー証明書の検証をカスタマイズ(例)
        ServicePointManager.ServerCertificateValidationCallback =
            new RemoteCertificateValidationCallback((sender, certificate, chain, sslPolicyErrors) => 
            {
                return sslPolicyErrors == SslPolicyErrors.None; // セキュリティ設定
            });

        Console.WriteLine("安全な接続が準備されました!");
    }
}

このコードは最も基本的な構造で、実際のNTSの具体的なプロトコルや機能を実現するには、専用ライブラリやドキュメントを参照して実装を拡張する必要があります。もっと詳しいことが必要なら教えてね!🌟

取り急ぎ、

  • NetworkClock で時刻を取得
  • NTP を使って時刻を取得
  • 差が n 秒以内なら NTP を信頼する

で良いのではないでしょうか。

おわりに

上記の例のようにアプリに埋め込む形で発行者の確認をする状態だと、NetworkClock の迂回は NTP 偽装よりちょっと難しい位だと思います。簡単にチェックをすり抜ける方法を突き止められてオレオレ証明書を使って突破されます。(多分)

理想はサーバーから SSL 証明書の Thumbprint を持ってくる、という流れなんですが、その最初の接続の安全性はどのように担保する? というジレンマがあります。

なので HEAD のレスポンスではなく、サーバーとやり取りをしてクライアントを認証するという処理の中の、安全性の確認が完了した際の HttpResponseMessage を渡してインスタンス化するというオーバーロードも用意してあります。

m_clock = NetworkClock.Create(response, TimeSpan.FromHours(9));

が! 結局ユーザーの管理する PC で実行されるアプリ内でどんな管理をしたところで、、、という感じなので、都度サーバーに問い合わせを行うのがベストでしょう。

証明書関連のアレコレ

ふわっとしたアレコレ

DigiCert 等の大手事業者が「証明書ピンニング」は不要・非推奨だと言っているのは Web ブラウザによって十分に安全が保障されている状態を前提としているからで、前提となっているブラウザそれ自体は「証明書ピンニング」によって安全を保障している可能性が高い。(AI談)グーグルの2023年のブログ

この分野の話はほぼ全て「ブラウザを通して提供されるサービス」を対象にしており、「第三者の攻撃から端末を守る」方法ばかりに注目している。悪意のある人間が自身の所有する(特権を持っている)端末を使って、その端末上で実行されるアプリに対し悪事を働くことは殆ど想定していない。

(※ブラウザ開発陣は想定しているので証明書のピンニングを行っている。「もう不要だ」「代替案がある」と言い続けながら、その一方で自社アプリ・サービスの安全性を高める為のピンニングを欠かすことがないのは、ピンニングが不要なのは「ブラウザ上で展開されるサービス」に限った話だから)

ユーザーの端末は「第三者に特権を奪われた状態のクラウドノード」と見ることもできる。その環境下で実行されるブラウザやゲーム・アプリが、特権ユーザーの介入を許さずに外部と安全に通信を行う方法は少ない。すべての起点となる「証明書の安全性を保障するための証明書」の安全性を保障するのは特に難しい。

https://zenn.dev/sator_imaging/scraps/5ec6338c6ef180

  • 証明書ピンニングするしかない?
  • 端末にルート証明書が 100 以上インストールされていたら起動禁止にする単純なやり方もアリ?
  • 大手プロバイダーの提供している「有効な証明書」の Thumbprint は大量に公開されている
  • TODO: Chrome は証明書のピンニングをしているらしいので(AI談)ソースコードから該当部分を探す

--

HTTPS 通信、SSL 証明書については明るくありません。間違いのご指摘お待ちしております。

以上です。お疲れ様でした。

Discussion

flano_yukiflano_yuki

勉強になりました!

TODO: Chrome は証明書のピンニングをしているらしいので(AI談)ソースコードから該当部分を探す

chromeが証明書の時刻エラーになったときに、サーバに時刻問い合わせるのはここらへんの仕組みですね!
(これによって、PCの時刻がずれてる旨のエラーが出せています
https://github.com/chromium/chromium/blob/3920be29f93f8b75f452ae5241982713f73da87e/components/network_time/network_time_tracker.cc#L137-L150

古いですか論文にも軽く書かれてたような気がします
https://dl.acm.org/doi/10.1145/3133956.3134007

サトー™サトー™

情報ありがとうございますー!
自分も AI に探させてたんですが、ピンニングのリストっぽいファイル群(*.pins)がありました。

https://github.com/chromium/chromium/blame/main/net/http/transport_security_state_static.pins

ルートは2036年まで有効なんで、ピンセットの最後の更新は数年前ですね。

(SSL 証明書の有効期間を最長3カ月にするべきだ! ってあなたたち言ってませんでした? っていう)