生成AIをアプリに組み込むと、1回の呼び出しごとに原価がかかります。無料枠を用意すると、その原価を払うのは運営者です。そして無料枠のAPIは、アプリからではなくスクリプトから叩かれます。
私は写真から暗記カードを作るAIアプリ『メモハック』を個人で開発し、2026年8月から App Store で公開しています。教材を撮影すると、AI が一問一答と穴埋めのカードを作り、FSRS / SM-2 の間隔反復で出題するアプリです(開発の全体像は MemoryHack AI の実装記録 にまとめています)。このアプリには、ログインしなくても使える無料のゲスト枠があります。この記事では、そのゲスト枠のAI呼び出しを、本物のアプリの本物のインストールからの呼び出しだけに限るために書いたサーバー側のコードを材料に、Apple App Attest と Google Play Integrity の検証を Python で実装する方法を解説します。
アプリ側で Swift から App Attest を呼ぶ部分は、React Native / Expo × Swift ネイティブモジュール実装ガイドで扱いました。この記事はその続きで、サーバーが受け取った証明をどう検証し、どう保存し、障害時にどう振る舞うかを扱います。
本記事の前提:プロトコルの記述は、Apple Developer の DeviceCheck ドキュメント(2026年9月に取得した版)と、Android Developers の Play Integrity ドキュメント(standard request のページは 2026-06-01 更新、verdicts は 2026-05-01 更新、setup は 2026-09-16 更新)に基づきます。実装は Python 3.13(AWS Lambda の
python3.13ランタイム)、cryptography50.0.0、cbor25.9.0、DynamoDB です。コードの出典は、メモハックの非公開リポジトリの 2026-09-25 時点の main ブランチです。本文では出典をファイル名で示します。
適用範囲について(正直に書きます):この記事の端末証明のゲートがかかるのは、無料のゲスト枠(ログインせずに使う人の生成とAI先生)だけです。ログイン済みの利用者はアカウント認証で、有料の利用者は購読で区別しているので、ゲートの対象外です。またゲートには
off/monitor/enforceの3段階があり、本番環境はまだenforce(拒否する段階)にしていません。リポジトリの負債台帳(backend/DEBT_TRACKER.mdの DEF-6)が、配布するアプリのバージョンと判定手順が揃うまでenforceにしないと定めています。本番がいまoffとmonitorのどちらなのかは、この記事では確認していません。Android 版は社内テストの段階で、Google Play には公開していません。Play Integrity の部分は、実装とテストまで済ませた段階の話です。
0. 結論:3つの層で守る
| 層 | 何を確かめるか | 何は確かめられないか | この実装での場所 |
|---|---|---|---|
| ① 端末の証明(App Attest / Play Integrity) | このリクエストが、本物の端末で動く自分たちの署名済みアプリから来たこと。しかも、その端末IDに結びついていること | 1台の端末が何個のIDを作るか | services/infra/attestation_service.py |
| ② 利用者ごとの枠 | 1つのIDが1日に使える回数 | IDをいくつ作られるか | core/quota.py |
| ③ 環境全体の日次上限 | その日に払う LLM の原価の上限 | 誰が使ったか | services/ai/spend_guard.py |
①だけでは、1台の実機から鍵を作り直してIDを量産されると防げません。②だけでは、IDの量産そのものを防げません。③は誰が使ったかを見ないので、①②が破られても請求額の上限だけは守られます。3つとも、他の層が破られることを前提に置いています。
1. アプリに API キーを埋めても防御にならない理由
最初に、よくある誤解を片づけます。「LLM の API キーをアプリに埋めて、アプリから直接呼ぶ」構成は論外です。キーは配布したバイナリの中にあり、そのバイナリは攻撃者の手元にあります。キーをサーバーに移しても、問題の形が変わるだけです。サーバーの無料枠APIを、何が「アプリからの呼び出し」だと判断するのでしょうか。
メモハックのゲストは、アプリが自分で生成した UUID を X-Device-ID ヘッダで送って識別されます。この値はアプリが作るので、誰にでも作れます。コードのコメントには、開発環境で実測した結果が残っています。新しい UUID を6個作って送ったら、6個とも無料枠を丸ごと受け取れたという記録です(2026-09-17、backend/services/ai/spend_guard.py)。UUID をループで作り直すスクリプトを書けば、無料枠を無制限に集められます。
必要なのは、攻撃者が手元で作れない値です。候補を比べると、次のようになります。
| 手段 | 攻撃者が手元で作れるか | 端末IDに結びつくか | 備考 |
|---|---|---|---|
| アプリに埋めた API キー | 作れる(取り出せる) | 結びつかない | 防御にならない |
| 端末が生成する UUID | 作れる | ― | 識別子であって証明ではない |
| Apple DeviceCheck のトークン | 作れない(本物の Apple 端末が必要) | 結びつかない(こちらが指定するデータを含められない) | 1台の本物の端末から、IDをいくつでも作れる |
| Apple App Attest | 作れない(Secure Enclave の鍵が必要) | 結びつく(こちらが発行したチャレンジを署名させられる) | 鍵はインストールごとに作られる |
| Google Play Integrity(standard) | 作れない(Google が署名した判定が必要) | 結びつく(requestHash にこちらの値を入れられる) | 判定はサーバーから Google に復号してもらう |
鍵になるのが「端末IDに結びつくか」の列です。DeviceCheck は「本物の Apple 端末が作ったトークンである」ことは証明しますが、こちらが指定したデータをトークンに含められません。そのため、1台の iPhone から取ったトークンを、使い捨てのIDごとに付け替えられます。メモハックでは実際に、DeviceCheck の判定が App Attest の拒否を上書きしてしまう不具合をレビューで見つけて直しています(DEF-6 の記録)。IDとの結びつきを証明できる手段だけに、最終的な判定を任せるのが、この記事の設計の軸です。
2. App Attest:チャレンジの発行と消費
App Attest の流れは2段階です(Apple「Establishing your app's integrity」)。
インストールごとに1回: challenge → generateKey → attestKey → サーバーへ attestation を送る
課金される呼び出しごと: challenge → generateAssertion → ヘッダに付けて送る
どちらの段階でも、サーバーが発行した使い捨てのチャレンジが先に必要です。Apple のドキュメントも、ランダムな値を発行して、検証のときに使えるよう覚えておくよう求めています。
2-1. 発行:32バイト、300秒、1発行1行
# backend/services/infra/app_attest_store.py(抜粋)
CHALLENGE_TTL_SECONDS = 300
_CHALLENGE_BYTES = 32
async def issue_challenge(device_id: str) -> bytes:
challenge = secrets.token_bytes(_CHALLENGE_BYTES)
now = int(time.time())
await asyncio.to_thread(
get_main_table().put_item,
Item={
**_challenge_key(device_id, challenge), # PK=DEVICE#{id}, SK=ATTEST_CHALLENGE#{b64}
"expires_at": now + CHALLENGE_TTL_SECONDS,
"TTL": now + CHALLENGE_TTL_SECONDS,
},
)
return challenge
設計上の判断が2つあります。
- 1つの端末が複数のチャレンジを同時に持てる。当初は端末ごとに1行で、発行のたびに上書きしていました。ところが、課金される POST を2つ同時に送ると、後から発行されたチャレンジが先のチャレンジを無効にしてしまい、本物の端末が拒否されました。いまは発行1回ごとに別の行を作ります。
- 有効期限を DynamoDB の TTL に任せない。TTL による削除はバックグラウンドで行われ、AWS の公式ドキュメントは「期限切れの項目は通常、期限後数日以内に削除される」としか書いていません。TTL だけに頼ると、期限切れのチャレンジが数日間使えてしまいます。そこで
expires_atを消費時の条件式に入れています。
2-2. 消費:検証より「先に」、条件付き書き込みで
# backend/services/infra/app_attest_store.py(抜粋)
async def consume_challenge(device_id: str, challenge: bytes) -> bool:
# 発行するチャレンジは常に32バイト。それ以外は検証するまでもなく使えない。
# 先に長さを見るのは、DynamoDB のソートキー上限(1KB)を超える値で
# ValidationException を起こさせ、UNAVAILABLE(=通す)に化けさせないため。
if len(challenge) != _CHALLENGE_BYTES:
return False
token = secrets.token_hex(16)
try:
await asyncio.to_thread(
get_main_table().update_item,
Key=_challenge_key(device_id, challenge),
UpdateExpression="SET #spent = :token",
ConditionExpression=(
"attribute_exists(PK) AND attribute_not_exists(#spent) AND #exp > :now"
),
ExpressionAttributeNames={"#spent": "spent_by", "#exp": "expires_at"},
ExpressionAttributeValues={":token": token, ":now": int(time.time())},
ReturnValuesOnConditionCheckFailure="ALL_OLD",
)
except ClientError as exc:
if refused_by_own_write(exc, attribute="spent_by", token=token):
return True # 自分の1回目の送信が消費済み。応答だけが失われた(10章)
if is_conditional_check_failed(exc):
return False
raise AppAttestStoreError("challenge_consume_failed") from exc
return True
呼び出し側(routers/attestation.py の鍵登録と、attestation_service.py の assertion 検証)は、どちらも暗号の検証より先にこのチャレンジを消費します。順序が逆だと、盗んだ attestation や assertion を、1つの生きたチャレンジに対して何度でも試せてしまいます。先に消費しておけば、試行は1チャレンジにつき1回で終わります。
条件式が attribute_exists(PK) から始まるのにも理由があります。UpdateItem は、該当する項目が無いと新しく作ってしまいます。存在チェックが無いと、発行していないチャレンジを送られたときに「消費済み」の行ができ、しかも成功扱いになります。
3. App Attest:attestation の検証(Apple の手順とコードの対応)
鍵の登録(POST /attestation/keys)で受け取る attestation は、CBOR でエンコードされた {fmt, attStmt: {x5c, receipt}, authData} です。authData の構造は WebAuthn の Authenticator Data と同じです。Apple の「Validating apps that connect to your server」は、検証手順を番号付きで示しています。メモハックの verify_attestation は、この手順を1つずつ、失敗理由の文字列(ログ専用で、クライアントには返しません)に対応させています。
| Apple の手順 | 内容 | コードでの失敗理由 |
|---|---|---|
| 1 | x5c は葉と中間の2枚。App Attest のルート証明書まで検証する | chain_length / chain_bad_signature / chain_expired |
| 2〜3 | clientDataHash = SHA256(challenge)、nonce = SHA256(authData ‖ clientDataHash) | ― |
| 4 | 葉の証明書の拡張 OID 1.2.840.113635.100.8.2 の中の OCTET STRING が nonce と一致する | nonce_mismatch / nonce_ext_* |
| 5 | 葉の公開鍵(X9.62 非圧縮点)の SHA-256 が鍵IDと一致する | key_id_mismatch |
| 6 | SHA256("<TeamID>.<BundleID>") が authData の RP ID と一致する | app_id_mismatch |
| 7 | counter が 0 | counter_not_zero |
| 8 | aaguid が本番なら appattest + 0x00×7、開発なら appattestdevelop | aaguid_mismatch |
| 9 | credentialId が鍵IDと一致する | cred_id_mismatch |
コードは次のとおりです(型の絞り込み用のヘルパーは省略しています)。
# backend/services/infra/app_attest_service.py(抜粋)
_NONCE_OID: Final = x509.ObjectIdentifier("1.2.840.113635.100.8.2")
_AAGUID_PRODUCTION: Final = b"appattest\x00\x00\x00\x00\x00\x00\x00"
_AAGUID_DEVELOPMENT: Final = b"appattestdevelop"
def verify_attestation(*, key_id, attestation, challenge, team_id, bundle_id,
production, now=None) -> AttestedKey:
now = now or datetime.now(UTC)
obj = _decode_map(attestation, "attestation_not_cbor")
_require(_field(obj, "fmt", str, "fmt_type") == "apple-appattest", "fmt_unexpected")
statement = _field(obj, "attStmt", dict, "att_stmt_type")
data = _field(obj, "authData", bytes, "auth_data_type")
x5c = _field(statement, "x5c", list, "x5c_type")
# 1. 固定したルートまでのチェーン
certs = [x509.load_der_x509_certificate(bytes(der)) for der in x5c]
cred_cert = _verify_chain(certs, now)
# 2-4. nonce = SHA256(authData || SHA256(challenge)) が葉の拡張に入っている
client_data_hash = hashlib.sha256(challenge).digest()
expected_nonce = hashlib.sha256(data + client_data_hash).digest()
_require(_extension_nonce(cred_cert) == expected_nonce, "nonce_mismatch")
# 5. 鍵IDは公開鍵の SHA-256
leaf_key = _uncompressed_public_key(cred_cert)
_require(hashlib.sha256(leaf_key).digest() == key_id, "key_id_mismatch")
# 6-9. 自分たちのアプリ・新しい鍵・想定した環境
expected_rp_id = hashlib.sha256(app_id(team_id, bundle_id).encode()).digest()
_require(data[0:32] == expected_rp_id, "app_id_mismatch")
_require(int.from_bytes(data[33:37], "big") == 0, "counter_not_zero")
_require(data[37:53] == (_AAGUID_PRODUCTION if production else _AAGUID_DEVELOPMENT),
"aaguid_mismatch")
cred_id_length = int.from_bytes(data[53:55], "big")
cred_id = data[55 : 55 + cred_id_length]
_require(len(cred_id) == cred_id_length and cred_id == key_id, "cred_id_mismatch")
return AttestedKey(key_id=key_id,
public_key_der=_public_key(cred_cert).public_bytes(
Encoding.DER, PublicFormat.SubjectPublicKeyInfo),
counter=0)
この実装で、特に意図して選んだ点を挙げます。
ルート証明書はファイルで固定する。 core/certs/apple_app_attest_root_ca.pem をリポジトリに置いています。コメントにあるとおり、「ネットワーク越しに取得した信頼の起点は、起点とは呼べない」からです。私の手元でもフィンガープリントを確かめました。固定したファイルと、Apple が公開している Apple_App_Attestation_Root_CA.pem の SHA-256 は、どちらも 1C:B9:82:3B:…:42:C9:32 で一致しました(openssl x509 -noout -fingerprint -sha256 で確認)。有効期間は 2020-03-18 から 2045-03-15 までです。このファイルが正しいことは、テスト test_pinned_root_is_apples がサブジェクトとフィンガープリントで固定しています。
有効期限はルートも含めて全段で見る。 署名だけを検証するチェーン検証は、期限切れの証明書も通してしまいます。
アルゴリズムは ECDSA に絞る。 Apple のチェーンは P-384 のルート、P-384 の中間、P-256 の葉です。それ以外の鍵の種類を受け付けても、偽造したチェーンが使える選択肢を増やすだけです。
ASN.1 のパーサーは書かない。 nonce の拡張は SEQUENCE { [1] { OCTET STRING } } という決まった形です。そこで短形式の DER だけを読む十数行の関数にとどめ、長形式が来たら拒否します。汎用の ASN.1 パーサーを持ち込むより、攻撃面が小さく済みます。
本番では開発用の aaguid を受け付けない。 開発用の鍵を本番で受け付けると、自分たちのチームの証明書で署名した開発ビルド(Xcode があれば誰でも作れます)が、App Store 版と同じ扱いで通ってしまいます。設定クラスは、本番環境で app_attest_production=False になっていると起動時に失敗します。Terraform の precondition も、plan の段階でこの組み合わせを止めます。
失敗理由はクライアントに返さない。 どの検証で落ちたかを教えるのは、偽造する側への無料のヒントです。正直なクライアントにとっては、どの失敗も「もう一度やり直す」という同じ意味しかありません。ルーターは失敗をすべて同じ 403 にまとめ、理由の文字列はログにだけ残します。ログがあるので、チームIDの設定ミスによる失敗と、本物の偽造とを運用側で見分けられます。
4. Apple 公式サンプルで検証器を試したら分かったこと
メモハックのテスト(backend/tests/services/infra/test_app_attest_service.py)は、冒頭にこう書いています。「Apple が署名したサンプルは存在しないので、同じ構造のチェーンを合成したルートの下で作り、固定しているルートを差し替えて検証する」。
ところが、この記事のために Apple のドキュメントを取り直したところ、「Attestation Object Validation Guide」に実物の attestation のサンプルが載っていました(Team ID 1234567890、バンドルID com.example.myapp、チャレンジ example_server_challenge)。そこで、このサンプルをメモハックの verify_attestation にそのまま通してみました。結果は次のとおりです。
| 試したこと | 結果 |
|---|---|
| 今日(2026-09-25)の時刻で検証 | chain_expired。葉の証明書の有効期間は 2026-04-20 18:13:12 UTC 〜 2026-04-23 18:13:12 UTC の3日間しかない |
now を 2026-04-21 に固定して検証 | 証明書チェーンは、固定している本物のルートまで通る。ただし nonce_mismatch |
| nonce の中身を調べる | 証明書に入っている nonce は SHA256(authData ‖ challenge)。つまりサンプルは、チャレンジをハッシュせずにそのまま clientDataHash として渡している。ドキュメントの本文(「チャレンジの SHA-256 を clientDataHash にする」)とは食い違う |
| チャレンジのハッシュ化だけをサンプルに合わせて検証 | 残りの手順(鍵ID、App ID、counter、aaguid、credentialId)はすべて通り、counter=0 の鍵として受理 |
ここから、実務で役立つことが3つ分かります。
clientDataHashの作り方は、クライアントとサーバーの間の取り決めであって、Apple が決めるものではない。App Attest は、渡された32バイトをそのまま nonce の計算に使います。メモハックのアプリは Swift 側でSHA256.hash(data: challenge)を渡し、サーバーも同じ計算をしています。片方だけを変えると、すべての証明がnonce_mismatchで落ちます。- 公式サンプルは回帰テストの材料に使える。ただし時刻を固定することが条件。
verify_attestationがnowを引数で受け取る設計になっているので、サンプルの有効期間内の時刻を渡せば、合成したチェーンではなく本物の Apple のルート証明書に対する検証をテストに入れられます(メモハックのリポジトリにはまだ入っていません)。 - ガイドの数値は鵜呑みにしない。ガイドの「公開鍵の SHA-256 の期待値」欄の値(
inGjK2…)は、私の手元の計算とは一致しませんでした。X9.62 非圧縮点の SHA-256 も、SubjectPublicKeyInfo(DER)の SHA-256 も、別の値になりました。鍵ID(zgSY9Y…)およびcredentialIdと一致したのは、手順5の本文どおりに計算した X9.62 非圧縮点のハッシュのほうです。
もう1つ、Apple のドキュメント同士の食い違いもあります。検証手順のページは開発環境の aaguid を appattestdevelop と書いていますが、「Preparing to use the App Attest service」のページは appattestsandbox と書いています。どちらも16バイトです。メモハックは前者を採用し、本番では開発用の値を一切受け付けません。開発ビルドの実物の attestation を1つ保存しておき、その aaguid を実際に確かめるのが確実です。
5. App Attest:assertion の検証と counter
登録が済んだら、課金される POST ごとに assertion({signature, authenticatorData})を付けて送ります。Apple の手順は、①clientData の SHA-256 を取り、②authenticatorData ‖ clientDataHash の SHA-256 を nonce とし、③保存済みの公開鍵で署名を検証し、④RP ID を照合し、⑤counter が前回より大きいことを確かめ、⑥clientData に埋め込んだチャレンジが発行したものと一致することを確かめる、というものです。
# backend/services/infra/app_attest_service.py(抜粋)
def verify_assertion(*, public_key_der, assertion, challenge, team_id, bundle_id,
stored_counter) -> int:
obj = _decode_map(assertion, "assertion_not_cbor")
signature = _field(obj, "signature", bytes, "assertion_signature_type")
auth_data = _field(obj, "authenticatorData", bytes, "assertion_auth_data_type")
_require(len(auth_data) >= 37, "assertion_auth_data_short")
expected_rp_id = hashlib.sha256(app_id(team_id, bundle_id).encode()).digest()
_require(auth_data[0:32] == expected_rp_id, "assertion_app_id_mismatch")
counter = int.from_bytes(auth_data[33:37], "big")
_require(counter > stored_counter, "assertion_counter_replay")
public_key = _load_public_key(public_key_der)
nonce = hashlib.sha256(auth_data + hashlib.sha256(challenge).digest()).digest()
try:
public_key.verify(signature, nonce, ec.ECDSA(hashes.SHA256()))
except InvalidSignature as exc:
raise AppAttestError("assertion_bad_signature") from exc
return counter
ここで重要なのは、検証が通ったあとに counter を保存する書き込みです。「読んで、比べて、書く」を別々に行うと、同じ assertion を2つの Lambda が同時に受け取ったとき、両方が「前回より大きい」と判定して通ってしまいます。そこで、比較を条件式の中に入れます。
# backend/services/infra/app_attest_store.py(抜粋)
await asyncio.to_thread(
table.update_item,
Key=_key_record_key(device_id), # PK=DEVICE#{id}, SK=ATTEST_KEY
UpdateExpression="SET #c = :new, #w = :w",
ConditionExpression="#k = :k AND #c < :new", # 検証に使った鍵のまま、かつ単調増加
ExpressionAttributeNames={"#c": "counter", "#k": "key_id", "#w": "counter_write_id"},
ExpressionAttributeValues={":new": counter, ":k": b64(key_id), ":w": token},
ReturnValuesOnConditionCheckFailure="ALL_OLD",
)
#k = :k を条件に入れている理由は、次章の「鍵の付け替え」です。assertion を検証してから counter を書くまでの間に、同じ端末IDの鍵が新しい鍵に置き換わることがあります。古い鍵の counter が新しい鍵の行に書き込まれると、新しい鍵の counter がその値を超えるまで、新しい鍵の assertion がすべて拒否されてしまいます。
設計上のトレードオフ:署名するのはチャレンジだけ
Apple のドキュメントは、clientData を「リクエストそのものをパッケージしたもの」と説明し、その中にチャレンジを埋め込む形を想定しています。メモハックは、チャレンジだけを clientData にしています(Swift 側で SHA256(challenge) を渡しています)。つまり、リクエストの本文は署名の対象外です。
この形でも、盗んだ assertion の再利用は防げます。チャレンジは1回しか使えないうえ、TLS で守られているからです。一方で、「本物の端末で署名だけを作り、それを別のリクエスト本文に付けて使う」ことは防げません。ただし、それができる攻撃者は、そもそも本物の端末を自由に操作できる立場にあります。メモハックのゲートが守る対象は「無料枠の生成1回」で、本文の中身(どの画像から問題を作るか)には攻撃する価値がありません。そのためこの割り切りにしています。送金額のように、本文そのものに価値があるAPIなら、本文のハッシュを clientData に含めるべきです。
6. 鍵の保存と付け替え:再インストールを前提にする
Apple のドキュメントには、App Attest の鍵はアプリのアップデートでは失われないが、再インストール、機種変更、バックアップからの復元では失われるとあります。一方で、端末IDを保存している Keychain の項目は、これらの操作のあとも残ります。つまり、「端末IDは生きているのに鍵は死んでいる」状態が、普通の利用者に日常的に起こります。
最初の実装は、端末IDへの鍵の登録を1回きり(attribute_not_exists)にしていました。この実装だと、再インストールした利用者は enforce の段階で永久に締め出されます。いまは、新しく検証できた attestation があれば、登録済みの鍵を置き換えます。
# backend/services/infra/app_attest_store.py(抜粋)
UpdateExpression=(
"SET #replaced = if_not_exists(#kid, :none), #kid = :kid, #pub = :pub, "
"#c = :c, #at = :at, #w = :w"
),
SET の右辺は更新前の項目を読むので、#replaced には置き換える前の鍵IDが入ります。戻り値から、BOUND(初回の登録)、RE_REGISTERED(同じ鍵の再送)、REBOUND(別の鍵への付け替え)の3つを区別し、REBOUND は WARNING でログに残します。
付け替えを許して安全な理由は、付け替えには必ずこの端末IDのために発行したチャレンジと、Apple のルートにつながる attestation が必要だからです。偽造もリプレイもできません。残るのは、「他人の端末IDを知っている、本物の端末を持つ人」がゲスト枠を自分の端末に移せる、という可能性です。ただし、端末IDはゲストにとってパスワードに相当する値です。それを知っている人は、もともとそのゲストのデータを読み書きできます。
クライアント側では、frontend/services/auth/app-attest.ts が2つの経路で鍵を作り直します。①署名したときに iOS が invalidKey(DCError.invalidKey)を返した場合と、②署名付きのリクエストがサーバーに 403 で拒否された場合です。どちらも1プロセスにつき1回までで、新しい鍵がサーバーに登録されてから保存済みの鍵IDを上書きします。Apple は、鍵の生成を再インストールや新しい利用者の追加のときだけに抑えるよう求めています。1台の端末の鍵の数が少ないほど、不正を見つけやすくなるからです。無条件に作り直す実装は、この方針に反します。
なお、Apple のドキュメントは「その公開鍵が別の利用者にすでに結びついていないことを確認する」ことも勧めています。メモハックは、鍵ごとに一意であることを明示的に確かめる処理を置いていません。この点は 12章の「実装していないこと」に含めます。
7. Play Integrity:requestHash で端末IDに結びつける
Android では Play Integrity の standard request を使います。公式ドキュメントの流れは、①トークンプロバイダを準備(ウォームアップ)し、②保護したい操作のハッシュを requestHash として渡してトークンを受け取り、③サーバーがそのトークンを Google のサーバーで復号して判定を読む、というものです。
7-1. requestHash の中身
# backend/services/infra/play_integrity_service.py(抜粋)
def expected_request_hash(device_id: str, challenge_b64: str) -> str:
# 端末ID・改行・サーバーが送ったチャレンジ(base64 のまま)の SHA-256 を hex で。
# 64文字なので、Google の上限(500バイト)に十分収まる
return hashlib.sha256(f"{device_id}\n{challenge_b64}".encode()).hexdigest()
アプリ側(frontend/services/auth/play-integrity.ts)も、同じ文字列を expo-crypto でハッシュします。Google は requestHash について、上限は500バイトで、機微な情報を平文で入れず、必ずハッシュするよう求めています。端末IDとチャレンジを入れておけば、あるIDのために作ったトークンは他のIDには使えず、使い回せば、消費済みのチャレンジに当たって拒否されます。standard request には Google 側のリプレイ防止もあり、同じトークンを繰り返し復号すると、端末の判定は空に、アプリの判定は UNEVALUATED になります。メモハックはそれに頼らず、自前のチャレンジでも止めています。
7-2. 判定の検証
decodeIntegrityToken が返すペイロードから、次の項目を順に確かめます。どれか1つでも欠けていれば REJECTED にします。
| 確認する項目 | 期待値 | 失敗理由 |
|---|---|---|
requestDetails.requestPackageName | 自分たちのパッケージ名 | request_package_mismatch |
requestDetails.requestHash | expected_request_hash(device_id, challenge) | request_hash_missing / request_hash_mismatch |
requestDetails.timestampMillis | 現在時刻から 300秒+60秒(時計のずれ)以内 | stale_token |
appIntegrity.appRecognitionVerdict | PLAY_RECOGNIZED | app_not_recognized |
appIntegrity.packageName | 自分たちのパッケージ名 | app_package_mismatch |
appIntegrity.certificateSha256Digest | 許可した署名証明書のどれか | certificate_not_allowed |
deviceIntegrity.deviceRecognitionVerdict | MEETS_DEVICE_INTEGRITY を含む | device_integrity_not_met |
MEETS_DEVICE_INTEGRITY について、Google のドキュメントは「Android 13 以降では、ブートローダーがロックされ、認定メーカーの OS イメージで起動していることを、ハードウェアに裏付けられた形で証明する」と説明しています。判定が空の場合は、root 化・API フック・Google Play の検証を通らないエミュレータのいずれかです。
ペイロードのモデルは、すべての項目を省略可能として定義しています。Google は、トークンに含まれなかった項目をペイロードから省くからです(使用済みのトークンは判定が空になり、classic request のトークンには requestHash ではなく nonce が入ります)。項目が無いことは「証明にならない判定」(REJECTED)として扱い、「応答を解析できなかった」(UNAVAILABLE)として扱ってはいけません。後者は通す側に倒れるので、トークンの中身で UNAVAILABLE を引き起こせると、それが検証をすり抜ける手段になってしまいます。
7-3. 呼び出す順序:チャレンジ → 認証情報 → 予算 → デコード
# backend/services/infra/play_integrity_service.py(抜粋)
if len(token) > _MAX_TOKEN_CHARS: # 16,384文字を超えるものは何も消費せずに拒否
return AttestationOutcome.REJECTED
if not await consume_challenge(device_id, challenge):
return AttestationOutcome.REJECTED
try:
access_token = await _access_token() # 自分たちの認証情報を先に確定させる
if not await spend_decode_budget(device_id):
return AttestationOutcome.REJECTED # この端末IDの今日の枠を使い切った
payload = await _decode(token, access_token)
except PlayIntegrityStoreError:
return AttestationOutcome.UNAVAILABLE
except PlayIntegrityUnavailableError:
return AttestationOutcome.UNAVAILABLE
この順序にしたのは、Google のクォータを守るためです。デコードのクォータは既定で1日10,000件で、Cloud プロジェクト単位で、classic request と standard request が共有します(setup ページの「使用量の上限」の表)。しかも、トークンがでたらめでも、デコードを依頼すればクォータは減ります(実装はこの前提で設計しています)。対策がなければ、1つの端末IDからでたらめなトークンを送り続けるだけで、その日の Android ゲスト全員の検証を止められてしまいます。
そこで、端末IDごとに1日30回のデコード予算を置いています(DAILY_DECODE_BUDGET = 30)。コメントに根拠の計算が残っています。無料ゲストが1日に課金される呼び出しは、初日で最大14回、2日目以降は9回です。30回はその2倍以上の余裕があり、クライアントの再試行も吸収できます。10,000 ÷ 30 で、クォータを使い切るにはおよそ334個の端末IDが必要になります(backend/services/infra/play_integrity_store.py)。
予算を消費するのは、自分たちのサービスアカウントの鍵でアクセストークンを取れたあとです。こちらの鍵が失効していた場合、そのリクエストは Google にデコードを依頼していないのに、端末の予算だけが減ってしまいます。すると、自分たちの設定ミスが、その端末IDにとって「その日の残りはずっと拒否」に変わってしまいます。
アクセストークンは、サービスアカウントの鍵で署名した JWT を https://oauth2.googleapis.com/token に送る、JWT Bearer フローで取得します(Google の「OAuth 2.0 for Server to Server Applications」)。取得したトークンは Lambda のコンテナ内に、期限の60秒前まで保持します。トークンエンドポイントが 400 / 401 / 403 を返したら、鍵が失効したものとみなします。キャッシュした鍵を捨て、1分間は Secrets Manager に取りに行きません。こうしておくと、鍵を差し替えたあとは自動で新しい鍵を拾い、差し替えるまでの間もリクエストのたびに Secrets Manager を呼ぶことはありません。
8. 失敗モード:どの障害で通し、どの障害で止めるか
端末の証明で一番難しいのは暗号ではなく、障害時の判断です。メモハックは、各プロバイダの結果を3つの値で表します。
GENUINE:本物の端末だと暗号で確認できたREJECTED:本物ではない(偽造、証明なし、使用済み)UNAVAILABLE:一時的な障害。ゲートは通す(fail-open)
さらに、プロバイダが無効になっている場合は None(棄権)を返し、結果の統合から外します。棄権を UNAVAILABLE として扱ってはいけません。それをすると、無効のプロバイダが「障害中」扱いになり、すべてのリクエストが通ってしまいます。つまり、enforce なのに何も拒否しない状態になります。実際にこの種の不具合がありました。DeviceCheck が未設定の環境で、トークン付きのリクエストが UNAVAILABLE になり、X-Device-Check-Token: x を付けるだけで enforce を通れていました(DEF-6 に記録、修正済み)。
| 状況 | 結果 | 理由 |
|---|---|---|
| DynamoDB の一時障害(チャレンジ・鍵の読み書き) | UNAVAILABLE → 通す | 攻撃者が起こせない障害で、利用者を止めない |
| 端末IDに鍵が登録されておらず、assertion も付いていない | App Attest は判定を保留し、Play Integrity に委ねる | Android のインストールも、偽造したIDも、この形になる |
| 鍵が登録されていないのに assertion が付いている | REJECTED | 偽造 |
Apple 側の attestKey が serverUnavailable を返した | アプリは「証明なし」で送る | Apple は、同じ鍵で後から再試行するよう案内している |
| Play Integrity のデコードのクォータ枯渇(429) | UNAVAILABLE だが、統合の段階で REJECTED に倒す | 攻撃者が起こせる障害だから |
| 自分たちのサービスアカウントの鍵が失効・取得不能 | 同上(REJECTED)+専用のログとアラーム | Android ゲストはその間拒否される。運用側が気づけるようにする |
| シミュレータ、App Attest 非対応の環境 | アプリは「証明なし」で送る。判定はサーバーのモードが決める | Apple によれば、Mac 上で動くアプリ(Apple シリコンの Mac で動く iOS アプリを含む)では isSupported が false になる |
| 有料の利用者 | ゲートの対象外 | 買ったものを、証明を出せない端末だから使えない、という事態にしない |
統合のルールは verify_guest_attestation に書かれています。IDとの結びつきを証明できるプロバイダ(App Attest と Play Integrity)が判定を出したら、それを答えにします。問い合わせたのに本物だと保証しなかった場合は REJECTED です。 DeviceCheck に問い合わせるのは、結びつきを証明できるプロバイダが何も答えられなかったときだけです。
# backend/services/infra/attestation_service.py(抜粋)
app_attest = await _verify_app_attest(proof)
if app_attest is AttestationOutcome.GENUINE or app_attest is AttestationOutcome.REJECTED:
return app_attest
play = await _verify_play_integrity(proof)
if play is AttestationOutcome.GENUINE:
return play
if play is not None:
# 問い合わせた以上、GENUINE 以外はすべて拒否。UNAVAILABLE は攻撃者が起こせる
if play is AttestationOutcome.UNAVAILABLE:
logger.warning("[METRIC] attestation_binding_unavailable provider=play_integrity")
return AttestationOutcome.REJECTED
段階的な有効化:off → monitor → enforce
ゲートのモードは off / monitor / enforce の3段階です。monitor では、検証はしますが拒否はせず、「enforce だったら拒否していた」件数をログに残します。評価のたびに1行、次の形式で出力します。
[METRIC] attestation_check decision=would_block outcome=rejected route=generation mode=monitor proof=none app_version=… device=…xxxxxx
この行を CloudWatch のメトリクスフィルタ4本(check・would_block・block・unavailable)で数えます。フィルタのパターンは logger = "core.quota" と message の前方一致で絞っています。単語の一致だけにすると、リクエストのパスや X-Request-Id ヘッダ(どちらもクライアントが自由に決められます)に同じ単語を入れるだけで、アラームを偽造したり薄めたりできるからです。enforce へ進む判断の基準は運用手順書(docs/ops/SECURITY_OPS.md §5.5)に書いてあります。たとえば「本番で monitor のまま7日以上、かつそのルートで check が200件以上」といった条件です。
Apple も、App Attest の有効化は段階的に進めるよう勧めています。大勢のアプリが一斉に attestKey を呼ぶとスロットリングされうるためで、目安として1日1,000万ユーザー以下、attestKey はアプリの全インストール合計で毎秒100リクエスト未満としています。
9. AI の原価に上限をかける:利用者ごとの枠は請求額の上限ではない
端末の証明が完璧でも、請求額に上限はかかりません。spend_guard.py のコメントには、その理由が3つ書かれています。
- IDは無料で作れる。App Attest があっても、1台の端末が作る鍵の数は制限できません。
- 不具合でカウンタと実際の呼び出しがずれる。実際に、AI先生の回答がガードに拒否されたときに枠を返金していたため、カウンタが動かないまま課金される Gemini 呼び出しを無制限に使える不具合がありました(2026-09-17 に修正。コメントには「8回中8回再現」と記録されています)。
- AWS の予算アラートは Gemini の請求を見られない。Gemini は Google が請求するからです。
そこで、アプリが LLM を呼ぶ唯一の関数(ai_service.generate_recorded)の手前に、環境全体の日次上限を置きました。
# backend/services/ai/spend_guard.py(抜粋)
async def reserve_ai_call(*, quota_type: str, now=None) -> AIBudgetReservation | None:
limit = settings.ai_daily_call_budget # 環境ごとの日次上限(JST)
if limit <= 0 or quota_type in PAID_QUOTA_TYPES:
return None # 有料の枠は数えない
bucket = jst_bucket(now or datetime.now(tz=UTC))
response = await asyncio.to_thread(
get_main_table().update_item,
Key=ai_budget_key(bucket),
UpdateExpression="ADD #count :inc SET #ttl = :ttl, #write = :write",
ConditionExpression=RESERVE_ONCE_CONDITION, # (#count 未作成 OR #count < :limit) AND 自分の再送でない
...,
ReturnValues="ALL_NEW",
ReturnValuesOnConditionCheckFailure="ALL_OLD",
)
spent = dynamo_int(response["Attributes"]["calls"])
if spent >= max(1, int(limit * 0.8)) and not response["Attributes"].get("warned"):
await _warn_once(bucket, spent=spent, limit=limit) # その日1回だけの警告
return AIBudgetReservation(bucket)
設計の要点は次のとおりです。
- 数える対象は「誰も払っていない呼び出し」だけ。有料の枠は購読料で原価をまかなえるので、対象外です。数えてしまうと、無料枠を量産する攻撃者が上限を使い切り、有料の利用者まで AI を使えなくなります。IDは無料でいくらでも作れるので、上限をどれだけ大きくしても、いずれは使い切られます。
- 単位はトークン数ではなく呼び出し回数。上限は呼び出しの前に確かめる必要があり、その時点ではトークン数が分かりません。機能ごとに出力の上限を決めてあるので、1回あたりの最悪の原価も決まります。
- プロバイダが応答しなかった呼び出しは1回分を返す。レート制限・利用不可・タイムアウトで応答が無かった場合は課金されないので、戻します。開発環境で測った日には、45回の試行のうち21回がプロバイダ側の失敗でした(コメントに記録)。これを戻さないと、プロバイダの障害中に再試行が上限を使い切り、障害が直ったあとも機能が止まったままになります。
- DynamoDB の障害時は止める(fail-closed)。ゲートの
UNAVAILABLEとは逆です。ここに届いた時点で、同じテーブルに対する利用者ごとの枠の確保がすでに成功しているので、新しい失敗の形を増やすことにもなりません。 - 80%の警告はその日に1回だけ。
warnedフラグを条件付き書き込みで取ったリクエストだけがログを出します。カウンタは返却によって下がることもあるので、「ちょうど80%」で判定すると、しきい値をまたぐたびに何度も警告が出てしまいます。
アラームは Terraform で2本定義しています(infra/main.tf)。
| アラーム | きっかけ | 評価期間 | 意味 |
|---|---|---|---|
ai-budget-warning | [METRIC] ai_budget_warning の行 | 300秒、しきい値 > 0 | 「今のうちに上限を上げるか判断する」 |
ai-budget-exhausted | [METRIC] ai_budget_exhausted の行 | 60秒、しきい値 > 0 | 「すでに拒否が起きている」。1件目の拒否でアラームを鳴らす |
2本に分けたのは、求められる対応が違うからです。前者は「決める」、後者は「すでに起きている」です。
AI の原価を守るための、エッジでのレート制限や Turnstile の組み合わせ方は、ログイン不要の生成AIチャットを『請求書破産』から守るで別の角度から扱っています。Web の場合はそちらの構成が中心になり、モバイルアプリの場合は本記事の端末証明が加わります。
10. 冪等性:SDK の再送を「リプレイ」と取り違えない
この実装で一番多くの不具合を出したのは、暗号ではなく再送でした。
boto3 の standard リトライモードは、初回を含めて既定で3回まで送信し、RequestTimeout や接続エラー、HTTP 500 / 502 / 503 / 504 のときに自動で再送します(Boto3「Retries」)。メモハックでは、読み取りのタイムアウトを3秒にしています(memoryhack_shared/dynamo.py の READ_TIMEOUT_SECONDS)。書き込みが DynamoDB で確定したのに応答だけが失われると、SDK は同じ書き込みをもう一度送ります。そして2回目の書き込みは、1回目がすでに書き換えた行に対して評価されます。
その結果、次のような不具合が起きていました(いずれもコミット履歴に修正の記録があります)。
- チャレンジの消費を削除で実装していたとき、再送は「行が無い」と判定され、自分で消費したばかりのチャレンジを「使えない」と答えて、本物の端末を拒否していた。
- counter の更新の再送が
#c < :newに拒否され、自分の assertion をリプレイと判定していた。 - 日次上限のカウンタが、1回の呼び出しで2回数えられていた。
直し方は、どの書き込みも同じ形です。呼び出しごとにトークンを作り、書き込む行にそのトークンを記録し、条件に失敗したときは ReturnValuesOnConditionCheckFailure="ALL_OLD" で返ってきた行に自分のトークンが入っているかを見ます。 入っていれば、それは自分の1回目の送信が成功していた、ということです。
# packages/memoryhack_shared/src/memoryhack_shared/dynamo.py(抜粋)
def own_write_row(exc, *, attribute: str, token: str):
if not isinstance(exc, ClientError) or not is_conditional_check_failed(exc):
return None
row = exc.response.get("Item") # ALL_OLD の行(DynamoDB のワイヤー形式)
if not isinstance(row, Mapping):
return None
stored = row.get(attribute)
if not isinstance(stored, Mapping) or stored.get("S") != token:
return None
return row # この行を書いたのは、この呼び出しの1回目の送信
ReturnValuesOnConditionCheckFailure は、条件チェックに失敗した UpdateItem について、失敗時点の項目の属性を返すパラメータです。AWS の API リファレンスは、読み取りキャパシティを消費しないと明記しています。
TransactWriteItems の ClientRequestToken(10分間は再送が何もしない操作になる)を使わなかった理由も、counter_resend.py に書いてあります。トランザクションが同じ行に対する通常の書き込みと重なると、その通常の書き込みが TransactionConflictException で失敗します。この例外は、boto3 のリトライ対象の一覧に入っていません。カウンタの行は、予約・返却・AI先生の枠など複数の経路から通常の UpdateItem で書かれるので、トランザクションに変えると、普段の同時実行で 503 や返金漏れが起きやすくなります。
11. テスト:何をどこで確かめているか
11-1. 暗号の検証:本物の構造を持つ合成チェーン
App Attest の attestation は、実機の Secure Enclave の鍵とチャレンジに縛られていて、期限も切れます。そのため単体テストでは、Apple と同じ構造のチェーン(P-384 のルート → P-384 の中間 → P-256 の葉、nonce の拡張つき)を合成したルートの下で作り、固定しているルートを差し替えます。署名は実際に検証され、nonce も実際に計算し直されます。各テストは、「1つの条件だけを壊すと検証が失敗する」ことを確かめます。
- 発行していないチャレンジ、別アプリの App ID、鍵IDと公開鍵の不一致、counter が 0 以外、期限切れの証明書、長さの違うチェーン、EC 以外の鍵、署名アルゴリズムの無い証明書、nonce 拡張の欠落、壊れた CBOR
- assertion のリプレイ(counter が同じ)、別の鍵による署名、別のチャレンジ、別アプリ
- 固定しているルートが本当に Apple のものか(
test_pinned_root_is_apples)
関連する8ファイル(App Attest の検証と保存、プロバイダの統合、Play Integrity の検証と予算、ルーター、メトリクスフィルタ、日次上限)を手元で実行したところ、293件がすべて成功しました(pytest -p no:cacheprovider --no-cov に8ファイルを渡して実行。パラメータ化したテストも1件と数えています)。
メトリクスフィルタのテスト(tests/core/test_attestation_metric_filters.py)は、コードが実際に出力するログ行を、Terraform に書いたフィルタのパターンに通します。ログのフィールドの順序を変えるとアラームが黙って鳴らなくなる、という事故をここで止めます。
11-2. 条件式:moto ではなく DynamoDB Local
条件付き書き込みの正しさは、moto では確かめられません。moto は条件式の文法を検証しないので、本物の DynamoDB なら拒否される式でも通ってしまいます。そこで、scripts/verify/ に DynamoDB Local に対して実行するハーネスを置いています(ls scripts/verify/*_ddb_check.py で34本)。この記事に関係するのは次の4本です。
| ハーネス | 確かめること |
|---|---|
app_attest_challenge_ddb_check.py | チャレンジの1回きりの消費、期限の最後の1秒、別端末のチャレンジ、8並列の競合、counter の更新と鍵の付け替えの再送 |
play_integrity_budget_ddb_check.py | 予算がちょうど30回で尽きること、8並列でも上限を超えないこと、端末IDごとに独立していること |
ai_spend_guard_ddb_check.py | 日次上限が8並列でも正確に守られること、予約・返却の再送が1回分として扱われること、80%の警告が再送されても1行だけ出ること |
counter_resend_ddb_check.py | 利用者ごとの枠の予約・返却の再送、上限ちょうどでの競合 |
再送の再現には、boto3 自身のリトライの仕組みを使います。
# scripts/verify/_sent_twice.py(抜粋)
class EveryUpdateSentTwice:
"""1回目の送信が確定したあとで、UpdateItem を1回だけ送り直させる。"""
def _resend_the_first(self, attempts: int, **_: object) -> int | None:
if attempts != 1:
return None
self.resent += 1
return 0 # botocore の needs-retry フックに「0秒後に再試行」と答える
def __enter__(self):
self._events.register_first("needs-retry.dynamodb.UpdateItem", self._resend_the_first)
return self
ハーネスの多くには対照実験も入れています。修正前の書き込みを同じように再送させ、実際に二重計上や自己拒否が起きることを示します。「修正後にテストが通る」だけでなく、「このハーネスは壊れた実装を見分けられる」ことまで確かめるためです。括弧の閉じ忘れのような壊れた式を送って、DynamoDB Local が拒否することも確かめています。なお、これらのハーネスは CI には入れていません。コンテナが起動しないだけで CI が赤くなるのを避けるためで、make ci-ddb-local で手動実行します。
11-3. クライアントとサーバーの対応
ゲートがかかるのは課金される POST だけなので、アプリは証明を付けるパスを許可リスト(frontend/services/http/attested-routes.ts)で持っています。frontend/__tests__/architecture/attested-routes-match-backend.test.ts がバックエンドのルーターのソースを読み、課金される POST が許可リストから漏れていたら失敗します。以前は、AI先生のパス(デッキIDとカードIDを含む)を固定の文字列で持っていたため、実際のURLと一致せず、enforce にすると無料ゲストの AI先生がすべて拒否される状態でした(DEF-6 に記録)。いまは区切りの数と :id のプレースホルダで照合し、このテストが許可リストの漏れを検出します。
12. 実装していないこと・限界
| 項目 | 状況 | 影響 |
|---|---|---|
本番の enforce | 未適用(DEF-6) | 現時点で本番は偽造したIDを拒否していない。monitor にしていれば件数は測れる |
App Attest の receipt と不正リスク指標(attestationData エンドポイント) | 使っていない | 1台の端末で作られた鍵の数を Apple に問い合わせられない |
| 端末ごとのID数の上限(DeviceCheck の2ビット、Play Integrity の device recall) | 未配線 | アプリのデータを消去すれば、同じ端末で新しいIDを作れる。③の日次上限が最後の防御線になる |
Apple の検証手順の新しい項目(apple_validation_category_01 / apple_bundle_version_01 の拡張、macOS の aclBlob) | 検証していない | 2026年9月時点のドキュメントには手順として載っている。iOS アプリの検証で必要かどうかは、実物の attestation で確かめてから判断する |
| 公開鍵が他のIDに結びついていないことの確認 | 明示的な確認は無い | Apple のドキュメントが勧める追加の対策 |
| リクエスト本文への署名 | しない(チャレンジのみ) | 本文に価値のあるAPIには向かない(5章) |
| Android 版の配布 | 社内テストのみ。Google Play には未公開 | Play Integrity の部分は、実機での本番運用の実績がまだ無い |
Apple 自身も「Assessing fraud risk」で、OS を改造した端末なら制限を回避しうると認めています。そのうえで、1台の侵害された端末から大量の偽アプリに assertion を配る攻撃への備えとして、不正リスク指標を用意しています。端末の証明は「コストを上げる」ための仕組みで、「不可能にする」ための仕組みではありません。だから③の日次上限が必要になります。
このコードが動いているアプリ
この記事のコードは、写真から暗記カードを作るAIアプリ『メモハック』のバックエンド(AWS Lambda + DynamoDB、Terraform で管理)から抜粋しました。撮影から非同期でカードを作るパイプラインや、オフラインでの復習の冪等化など、ほかの設計は MemoryHack AI の実装記録にまとめています。モバイルアプリのセキュリティ全体(トークンの保管、通信の保護、改ざん耐性)を OWASP MASVS の領域ごとに整理した記事は、モバイルアプリのセキュリティ実装ガイドです。
生成AIを組み込んだモバイルアプリで、「無料枠をスクリプトに食われないか」「障害時にどちらへ倒すか」「請求額に上限をかけられるか」を設計段階から検討したい場合は、開発のご相談も受け付けています。