リワード広告を一本見たら、有料の壁紙を一枚だけ解放できる。そんな小さな導線を Antigravity のエージェントに頼んだとき、返ってきたコードは確かに動きました。広告が閉じた瞬間に壁紙が解放され、次に開いたときも解放されたままです。ただ、その「解放されたまま」を支えていたのは、端末のローカル設定に書き込まれた一個の真偽値だけでした。
私は個人開発で壁紙・ヒーリング系のアプリを Google Play で運用しています。課金のない代わりに、リワード広告の視聴で一部の壁紙を解放する設計を長く使ってきました。だからこそ、この実装を一目見て手が止まりました。これは、解放という報酬付与を誰が決めるのか、という信頼境界の話だからです。
「広告を見たら解放」を、エージェントは一番短い道で実装した
エージェントが書いたのは、おおよそ次のような形でした。広告の視聴完了コールバックで、その壁紙の解放フラグをそのまま端末に書き込みます。
// エージェントが最初に提案した版(クライアント側だけで付与)
rewardedAd.show(activity) { rewardItem ->
// 視聴完了 = 即・解放を端末に永続化
prefs.edit()
.putBoolean("wallpaper_${wallpaperId}_unlocked", true)
.apply()
unlockUi(wallpaperId)
}
挙動としては正しく見えます。広告を見れば解放され、再起動しても残ります。テストでも問題は出ません。けれども、この一行 putBoolean(... true) が「壁紙が解放されたか」の唯一の根拠になっている点が、本番では弱点になります。
端末のローカル設定は、ユーザーの手の届く場所にあります。root 化された端末や改変したビルドでは、広告を一度も見ずにこのフラグを true にできます。広告のコールバック自体も、クライアントの中で完結している限りは差し替えの対象です。つまり「広告を見た」という事実と「解放してよい」という判断が、どちらもクライアント側に閉じていました。
少額とはいえ、これは広告収益の取りこぼしに直結します。リワード広告は「視聴という対価と引き換えに報酬を渡す」契約で成り立っているのに、対価を払わずに報酬だけ取れる経路を残してしまうからです。
なぜクライアント側の付与だけでは足りないのか
ここで効いてくるのが、AdMob のサーバーサイド検証(Server-Side Verification, SSV)です。リワード広告ユニットで SSV を有効にすると、ユーザーが報酬を得るたびに Google が私のサーバーへコールバックURLを叩いてくれます。このコールバックには、報酬を「付与してよい」とGoogleが認めた事実が、ECDSA の署名つきで載っています。
公式ドキュメントでも、クライアント側のコールバックは即時のUX用に使い、報酬の正当性はSSVで検証するのが推奨とされています(Validate server-side verification (SSV) callbacks)。アプリ内経済に影響する報酬ほど、サーバーの検証済みコールバックを正とすべきだと明記されています。私の壁紙解放はまさに「経済に影響する報酬」でした。
設計として持ち帰るべき線は一本です。「広告を見た」という計測はクライアント(と広告SDK)に任せてよい。しかし「解放してよい」という付与の判断は、署名を検証できるサーバーだけが下す。 エージェントが短絡したのは、この二つを一つにまとめてしまった点でした。
| 観点 | クライアント側だけの付与 | SSV で検証した付与 |
| 解放の根拠 | 端末のローカルフラグ | Googleが署名したコールバック |
| 詐称耐性 | フラグ書き換え・APK改変で突破可能 | 署名検証を通らない付与は弾ける |
| 二重付与 | 検知できない | transaction_id で冪等化できる |
| 真実の所在 | 端末 | サーバー |
報酬付与をまたぐ信頼境界を一本に決める
実装の前に、付与のフローを設計し直しました。鍵は、解放のリクエストごとにサーバー発行の不透明なIDを一つ持たせ、それを AdMob の custom_data に載せて往復させることです。こうすると、後からSSVコールバックが届いたとき、どの解放リクエストに対応する報酬なのかをサーバーが一意に突き合わせられます。
- アプリがサーバーに「この壁紙を解放したい」と要求し、サーバーは使い捨ての
grantRequestId を発行して保留状態で記録する
- アプリはその
grantRequestId を custom_data に、安定したユーザー識別子を user_id に積んでリワード広告を表示する
- 視聴が完了すると、Googleが私のSSVエンドポイントへ署名つきコールバックを送る
- サーバーは署名を検証し、
transaction_id で重複を除いたうえで、対応する grantRequestId を「解放済み」に確定する
- アプリはサーバーの解放状態を読み、UIを更新する
クライアントは視聴直後に楽観的に解放表示を出してかまいません。ユーザー体験は損ねないからです。ただし、永続的な真実はあくまでサーバーの確定にあります。
クライアント側: 楽観的UXと「真の付与は別」
Kotlin 側は、ServerSideVerificationOptions にサーバー発行のIDとユーザーIDを載せるだけです。ここで端末に解放フラグを書き込まない、という一点が前の版との違いです。
// 解放リクエストはサーバーが発行した不透明ID。端末で生成しない。
val options = ServerSideVerificationOptions.Builder()
.setCustomData(grantRequestId) // 例: "g_8f3c...d21" サーバー発行・使い捨て
.setUserId(stableUserId) // 端末横断で安定したID(メアド等の生値は載せない)
.build()
rewardedAd.setServerSideVerificationOptions(options)
rewardedAd.show(activity) { _ ->
// 楽観的UXのみ。ここでは「解放した」を永続化しない。
showOptimisticUnlock(wallpaperId)
// 真の解放状態はサーバーから取得する
viewModel.refreshEntitlement(grantRequestId)
}
custom_data は SSV コールバックでパーセントエンコードされて届くため、サーバー側でデコードが要ります。user_id も custom_data も、未設定だとコールバックにそのパラメータ自体が現れない点に注意してください。
SSV コールバックを検証する(Cloudflare Worker)
検証エンドポイントは Cloudflare Worker に置きました。Web Crypto だけで完結します。手順は公式の手動検証と同じで、(1)検証鍵の取得とキャッシュ、(2)署名対象テキストの切り出し、(3)署名と key_id の取り出し、(4)ECDSA 検証、の四つです。
ここに一つ、ドキュメントのサンプル(Tink/Java)からそのまま移すと必ず詰まる罠があります。AdMob の署名は DER エンコードですが、Web Crypto の verify は生の r‖s(P1363, P-256なら64バイト)しか受け取りません。 DER をそのまま渡すと、署名は正しいのに検証が常に false になります。下のコードでは derToRaw で変換しています。
const VERIFIER_KEYS_URL = "https://www.gstatic.com/admob/reward/verifier-keys.json";
let keyCache = { at: 0, keys: null };
// 鍵は24時間以上キャッシュしない(鍵はローテートされる)
async function getKeys() {
const now = Date.now();
if (keyCache.keys && now - keyCache.at < 24 * 60 * 60 * 1000) return keyCache.keys;
const res = await fetch(VERIFIER_KEYS_URL, { cf: { cacheTtl: 3600 } });
const json = await res.json();
const map = new Map();
for (const k of json.keys) map.set(String(k.keyId), k.base64); // base64 は DER SPKI
keyCache = { at: now, keys: map };
return map;
}
function b64urlToBytes(s) {
s = s.replace(/-/g, "+").replace(/_/g, "/");
while (s.length % 4) s += "=";
const bin = atob(s);
return Uint8Array.from(bin, (c) => c.charCodeAt(0));
}
function b64ToBytes(s) {
const bin = atob(s);
return Uint8Array.from(bin, (c) => c.charCodeAt(0));
}
// DER(ECDSA) -> 生の r‖s 64バイト(P-256)。Web Crypto はこちらを要求する。
function derToRaw(der) {
let o = 0;
if (der[o++] !== 0x30) throw new Error("bad DER");
if (der[o] & 0x80) o += (der[o] & 0x7f) + 1; else o++;
const readInt = () => {
if (der[o++] !== 0x02) throw new Error("bad DER int");
let len = der[o++];
let v = der.slice(o, o + len); o += len;
while (v.length > 1 && v[0] === 0x00) v = v.slice(1); // 先頭0x00を除去
return v;
};
const r = readInt(), s = readInt();
const out = new Uint8Array(64);
out.set(r, 32 - r.length);
out.set(s, 64 - s.length);
return out;
}
async function verifySsv(rawQuery) {
// 署名対象 = "&signature=" の直前まで。順序もエンコードも一切変えない。
const sigMarker = "&signature=";
const i = rawQuery.indexOf(sigMarker);
if (i === -1) return false;
const signedContent = rawQuery.slice(0, i);
const params = new URLSearchParams(rawQuery);
const keyId = params.get("key_id");
const sigB64url = params.get("signature");
if (!keyId || !sigB64url) return false;
const keys = await getKeys();
const spki = keys.get(String(keyId));
if (!spki) return false; // 鍵が見つからない=キャッシュ更新が必要かもしれない
const pub = await crypto.subtle.importKey(
"spki", b64ToBytes(spki),
{ name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]
);
const rawSig = derToRaw(b64urlToBytes(sigB64url));
return crypto.subtle.verify(
{ name: "ECDSA", hash: "SHA-256" },
pub, rawSig, new TextEncoder().encode(signedContent)
);
}
signedContent を URLSearchParams で組み直さずに、生のクエリ文字列から切り出している点も重要です。パラメータの順序やエンコードを一文字でも変えると署名が合わなくなります。
二重付与とリプレイを transaction_id で止める
署名が通っても、まだ終わりではありません。同じコールバックが二度届く可能性があるからです。Google は応答が得られないと最大5回・1秒間隔で再送します。署名は何度でも正しく通るので、署名検証だけでは二重付与を防げません。
そこで transaction_id(付与イベントごとに一意な16進ID)を冪等キーに使います。KV へ初出のときだけ書き込み、すでにあれば付与をスキップします。
export default {
async fetch(req, env) {
const url = new URL(req.url);
const rawQuery = url.search.slice(1); // 先頭の "?" を除く生クエリ
const ok = await verifySsv(rawQuery);
if (!ok) return new Response("invalid signature", { status: 403 });
const p = new URLSearchParams(rawQuery);
const txId = p.get("transaction_id");
const grantRequestId = decodeURIComponent(p.get("custom_data") || "");
const ts = Number(p.get("timestamp") || 0);
// 鮮度チェック(任意): 古すぎるコールバックは捨てる
if (ts && Date.now() * 1000 - ts > 10 * 60 * 1_000_000) {
return new Response("stale", { status: 200 }); // 200 を返し再送は止める
}
// 冪等化: transaction_id 初出のときだけ付与
const seenKey = `ssv:txn:${txId}`;
if (await env.GRANTS.get(seenKey)) {
return new Response("dup", { status: 200 }); // 既処理。200で再送を止める
}
await env.GRANTS.put(seenKey, "1", { expirationTtl: 60 * 60 * 24 * 30 });
// 対応する解放リクエストを確定(保留→解放済み)
await env.GRANTS.put(`grant:${grantRequestId}`, "unlocked", { expirationTtl: 60 * 60 * 24 * 365 });
return new Response("ok", { status: 200 }); // Google は 200 を期待する
},
};
検証に失敗したときは 403 を返してかまいませんが、すでに処理した・古い・重複といった「正当だが付与不要」のケースでは 200 を返すのが肝心です。200 以外だと Google が再送を続け、ログが無用に膨らみます。アプリ側は grant:{grantRequestId} の状態を読み、unlocked になったら本解放としてUIと描画を確定します。
計測はエージェントに、信頼境界は自分に
この設計に切り替えて約2週間、自分の壁紙アプリで観察しました。クライアントが楽観的に「解放した」と表示したイベントのうち、対応するSSVの検証を通らなかったものが約3.4%ありました。以前はこの3.4%がそのまま無償の解放として通っていたことになります。検証を正にしてからは、署名を通らない付与はゼロになり、再送由来の二重付与も transaction_id の冪等化で消えました。
エージェントは優秀でした。広告のロード、コールバック配線、UIの解放表示まで、面倒な配線を正確に書いてくれます。けれども「報酬を付与してよいか」という判断は、性質上いちばん短い道に流れます。クライアントで完結する実装が、テストも通り、デモでも動くからです。だからこそ、付与のような信頼境界は人がレビューで握ると決めておくのが現実的だと感じています。
私はこの種の作業では、配線と計測はエージェントに任せ、署名検証・冪等化・付与の確定という三点だけは自分の手でコードを読む、という線を引くようにしています。まず手元のリワード広告ユニットで SSV を有効にし、上の Worker に検証だけを通す状態を作ってみてください。付与の真実がどこにあるべきかが、その一歩で具体的に見えてきます。