スタディサプリ Product Team Blog

株式会社リクルートが開発するスタディサプリのプロダクトチームのブログです

Signed Query は GraphQL の Trusted Document の新しい実装パターンです

こんにちは。スタディサプリの小中新規開発チームで Web エンジニアをしている @YutaUra です。 去年の4月に新卒で入社をしまして約 1 年が経ちました。インターン生時代にもブログを書いているのでご興味あれば合わせてご覧ください。

GraphQL と Persisted Query

スタディサプリ小中講座ではデータ通信に GraphQL を採用しています。

GraphQL を利用することで、クライアントはスキーマに定義された範囲で自由にデータを取得することができます。

query GetUser {
  user {
    name
    age
  }
}

また、 GraphQL はデータのグラフ構造に基づいて関連する複数のデータを一度に取得することができます。

query GetUser {
  user {
    name
    age
    posts {
      title
      content
    }
  }
}

GraphQL の柔軟性は大きなメリットを提供しますが、同時に無制限のクエリがサーバーに過剰な負荷をかけるリスクも伴います。

例えば以下のようなクエリの実行によって、サーバーに過剰な負荷がかかることは容易に想像できるでしょう。

query EvilGetUser {
  user {
    posts {
      likeUser {
        posts {
          likeUser {
            posts {
              # deep nesting
            }
          }
        }
      }
    }
  }
}

我々のユースケースとして想定していないクエリは、サーバーに過剰な負荷をかけることもあります。

この課題には幾つかの対応策があり、その一つが Persisted Query1 です。また、 Persisted Query は Trusted Document と呼ばれること2もあります。

Persisted Query は、あらかじめアプリケーションで利用するクエリを GraphQL サーバーに登録し、登録されたクエリのみリクエストを受け付ける仕組みです。 Persisted Query では、事前にクエリのハッシュ値を計算し、そのハッシュ値をクライアントが送信することで、サーバーは受け取ったハッシュ値に対応するクエリを実行します。

Persisted Query のアーキテクチャ

上記の図のように、クライアントアプリのビルド時にクエリをハッシュ化し、ハッシュ値をアプリのアセットとして埋め込み、ハッシュ値とクエリのペアは GraphQL サーバーに登録されます。

事前にクエリのハッシュ値を計算し、リクエストにはハッシュ値のみを用いることで、通信量の削減などと言った副次的なメリットも享受できます。 また、クエリの内容を静的に管理することで、過去にリリースしていたアプリのクエリの内容を追跡することも容易になるなどのメリットもあります。

{
  // "query": "query GetUser { user { name age } }",
  "operationName": "GetUser",
  "variables": {},
  "extensions": {
    "persistedQuery": {
      "version": 1,
      "sha256Hash": "xxxxxxxxxx" // send hash value instead of query
    }
  }
}

2022 年にリリースされたスタディサプリ中学講座や、昨年 9 月にリリースされたスタディサプリ小学講座でも Persisted Query を活用していました。

スタディサプリ小学・中学講座と Persisted Query の課題

Persisted Query は GraphQL サーバーを保護するための有効な手段であった一方で、2 つの課題がありました。

1 つ目の問題は Persisted Query の登録および登録忘れを防ぐ仕組みの複雑性です。Persisted Query を利用する以上、クエリの登録を忘れることは致命的な問題となります。Persisted Query の登録をしないままアプリをリリースしてしまうと、GraphQL リクエストが受け付けられず、アプリが正常に動作しなくなる可能性があります。 私たちの組織では、 Persisted Query の登録自体は自動化されていたものの、その後 GraphQL サーバーを手動でデプロイをする必要があるため、登録忘れが発生する可能性がありました。 iOS, Android, Web のリリースサイクルに合わせた Persisted Query の登録と、各アプリケーションのリリース時に Persisted Query が登録済みか確認するワークフローは認知負荷が高く、生産性を低下させる要因となっていました。

2 つ目の問題は、1 つ目の問題に関連していますが、私たちのチームでは Persisted Query の登録後 GraphQL サーバーの再デプロイが必要であったことです。GraphQL サーバーは Persisted Query の登録以外にも様々な変更が発生するため、 GraphQL サーバーのデプロイをする際にはそれらの変更がリリースされることになります。 特に普段バックエンドの開発を担当しないエンジニアにとっては、予期せぬリリースがないか確認しながらデプロイを行う必要があり、好ましい状況とは言えませんでした。またアプリの動作確認のために GraphQL サーバーを遅い時間にデプロイすることも少なくなく、こちらも改善が必要であると感じていました。

これらの問題に対して、 GraphQL サーバーのデプロイをせずに Persisted Query の登録を行う手法として データベースや Redis 等に Persisted Query を保存する方法も考えられましたが、障害点の増加や Persisted Query の管理がより複雑になることを懸念し、 Signed Query という新しい Trusted Document の実装パターンを考案しました。

Signed Query とは

Signed Query とは共通鍵を用いたメッセージ認証を利用して、クライアントが送信するクエリが信頼されたものであることを保証する仕組みです。

Signed Query のアーキテクチャ

Signed Query ではアプリのビルド時にクエリとクエリに対応する署名をアプリに埋め込みのみで、GraphQL サーバーへ事前に登録などを行う必要はありません。

クライアントアプリをビルドする際に、事前にクエリの HMAC を計算し、クライアントアプリに埋め込みます。そして、クライアントアプリはクエリと HMAC を送信し、サーバーはクエリを実行する前に HMAC を検証することで、クエリが信頼されたものであることを保証します。

Signed Query であれば一度鍵を共有する仕組みを構築してしまえば、アプリの方は単体でのリリースが可能になり、GraphQL サーバーも再デプロイが不要になります。

また共通鍵はアプリケーションのビルド時に参照されるのみで、実行時に参照されることはないため、共通鍵の漏洩する可能性は極めて低いです。

私たちのチームでは、新たな Trusted Document の実装としてこの Signed Query を採用し、 Persisted Query の運用課題を解決しました。

また、 Persisted Query としてクエリを静的に管理する仕組みは残しており、ユースケースを一覧する目的のみで利用しています。

Signed Query の技術的な仕組み

HMAC を作成するためにアプリケーションで利用されているクエリを収集する必要があります。幸いにもクエリを収集する仕組みは GraphQL Codegen や Persisted Query を生成するツールを利用することで簡単に実現できます。

次にクエリに対応する HMAC を作成します。私たちのチームでは Web のプロジェクトには GraphQL Codegenプラグインを実装し、 iOS, Android のプロジェクトには Go-lang で簡単なスクリプトを書くことで HMAC を生成する仕組みを構築しました。 またデバッグ用アプリには API の向き先として本番環境とステージング環境を切り替える仕組みがあり、それぞれで共通鍵も異なるためデバッグ用ビルドでは HMAC も 2 つずつ生成するようにしています。

GraphQL Over HTTP spec には Request Parameter として extensions という任意の拡張を行うためのフィールドが用意されています。 Persisted Query のクエリハッシュもこの extensions フィールドに格納されており、 Signed Query でも同様に extensions フィールドに HMAC を格納します。

具体的な Request Parameter として以下のようなデータを送信するように GraphQL Client の実装を行いました。

{
  "query": "query Home { ...}", // query is needed for Signed Query
  "operationName": "Home",
  "variables": {},
  "extensions": {
    "signedQuery": {
      "signature": "xxxxxxxxxx"
    }
  }
}

GraphQL サーバーでは query に対応する HMAC を計算し、 extensions.signedQuery.signature と比較することで検証を行う実装を作成しました。

HMAC の生成コストは数百 ns 程度であり、全体の処理時間に対して無視できるほどのコストであるため、サーバー側での検証はパフォーマンスに影響を与えることはありません。

ただし、共通鍵の管理には十分な注意が必要です。私たちは AWS Secrets Manager に鍵を保存し、アプリケーションをビルドする時のみ鍵を参照するようにするなどの対策を行っています。

Signed Query の課題

Signed Query は Persisted Query の課題を解決するための新しい Trusted Document の実装パターンとして有効である一方で、課題があることも認識しています。

主な課題としては、鍵のローテーションや、漏洩した鍵で作成された HMAC を拒否する仕組みの実装が挙げられます。今後もこの課題に対して対策を講じていく予定です。

まとめ

Signed Query は GraphQL の Trusted Document の新しい実装パターンとして、クライアントアプリのリリースと GraphQL サーバーのデプロイを分離することで、開発プロセスの合理化を実現します。

スタディサプリ小学・中学開発チームでは GraphQL を用いた開発を行っており、これからも新しい技術を取り入れながら、より良いサービスを提供していきたいと考えています。


  1. Automatic Persisted Queries は Persisted Query とは別物なので注意が必要です。
  2. https://benjie.dev/graphql/trusted-documents