const ADMOB_PUBLIC_KEYS_URL = 'https://www.gstatic.com/admob/reward/verifier-keys.json' const MAX_KEY_CACHE_MS = 24 * 60 * 60 * 1000 const encoder = new TextEncoder() interface AdMobPublicKeyRecord { keyId?: unknown base64?: unknown } interface AdMobPublicKeyResponse { keys?: AdMobPublicKeyRecord[] } interface CachedKeys { expiresAtMs: number keys: Map } export interface ParsedAdMobCallback { signedContent: string signature: Uint8Array keyId: string params: URLSearchParams } export class AdMobSsvError extends Error { constructor( public readonly code: string, public readonly status: number, ) { super(code) this.name = 'AdMobSsvError' } } let keyCache: CachedKeys | null = null // ── 리플레이 방어(1차, isolate 내 캐시) ───────────────────────────── // 리워드 콜백의 transaction_id는 AdMob에서 유일하다. TTL은 하루 지급 상한 // 대비 여유(25시간). 2차 방어는 수신측 DB 유니크 제약이 담당한다. const SSV_REPLAY_TTL_MS = 25 * 60 * 60 * 1000 const SSV_MAX_AGE_MS = 60 * 60 * 1000 const SSV_MAX_FUTURE_MS = 60 * 60 * 1000 const replaySeenTransactions = new Map() function decodeBase64(value: string): Uint8Array { const normalized = value.replace(/-/g, '+').replace(/_/g, '/') const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=') try { const binary = atob(padded) return Uint8Array.from(binary, (character) => character.charCodeAt(0)) } catch { throw new AdMobSsvError('invalid_signature_encoding', 400) } } function readDerLength(bytes: Uint8Array, offset: number): { length: number; next: number } { const first = bytes[offset] if (first === undefined) throw new AdMobSsvError('invalid_signature_encoding', 400) if ((first & 0x80) === 0) return { length: first, next: offset + 1 } const byteCount = first & 0x7f if (byteCount < 1 || byteCount > 2 || offset + byteCount >= bytes.length) { throw new AdMobSsvError('invalid_signature_encoding', 400) } let length = 0 for (let index = 0; index < byteCount; index += 1) { length = (length << 8) | bytes[offset + 1 + index] } return { length, next: offset + 1 + byteCount } } function readDerInteger(bytes: Uint8Array, offset: number): { value: Uint8Array; next: number } { if (bytes[offset] !== 0x02) throw new AdMobSsvError('invalid_signature_encoding', 400) const lengthInfo = readDerLength(bytes, offset + 1) const end = lengthInfo.next + lengthInfo.length if (lengthInfo.length < 1 || end > bytes.length) { throw new AdMobSsvError('invalid_signature_encoding', 400) } let value = bytes.slice(lengthInfo.next, end) while (value.length > 32 && value[0] === 0) value = value.slice(1) if (value.length > 32) throw new AdMobSsvError('invalid_signature_encoding', 400) return { value, next: end } } export function derEcdsaSignatureToRaw(signature: Uint8Array): Uint8Array { if (signature[0] !== 0x30) throw new AdMobSsvError('invalid_signature_encoding', 400) const sequence = readDerLength(signature, 1) if (sequence.next + sequence.length !== signature.length) { throw new AdMobSsvError('invalid_signature_encoding', 400) } const r = readDerInteger(signature, sequence.next) const s = readDerInteger(signature, r.next) if (s.next !== signature.length) throw new AdMobSsvError('invalid_signature_encoding', 400) const raw = new Uint8Array(64) raw.set(r.value, 32 - r.value.length) raw.set(s.value, 64 - s.value.length) return raw } function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { const buffer = new ArrayBuffer(bytes.byteLength) new Uint8Array(buffer).set(bytes) return buffer } async function fetchPublicKeys(fetchImpl: typeof fetch): Promise> { let response: Response try { response = await fetchImpl(ADMOB_PUBLIC_KEYS_URL, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(10_000), }) } catch { throw new AdMobSsvError('admob_keys_unavailable', 503) } if (!response.ok) throw new AdMobSsvError('admob_keys_unavailable', 503) const body = await response.json() as AdMobPublicKeyResponse const keys = new Map() for (const record of body.keys ?? []) { if ( (typeof record.keyId !== 'number' && typeof record.keyId !== 'string') || typeof record.base64 !== 'string' || !record.base64 ) continue try { const key = await crypto.subtle.importKey( 'spki', toArrayBuffer(decodeBase64(record.base64)), { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify'], ) keys.set(String(record.keyId), key) } catch { // Ignore a malformed individual key. An empty usable keyset fails below. } } if (keys.size === 0) throw new AdMobSsvError('admob_keys_unavailable', 503) const nowMs = Date.now() const cacheControl = response.headers.get('cache-control') ?? '' const maxAgeMatch = cacheControl.match(/(?:^|,)\s*max-age\s*=\s*(\d+)/i) const ageSeconds = Number(response.headers.get('age') ?? '0') let freshnessMs = 0 if (maxAgeMatch) { const maxAgeSeconds = Number(maxAgeMatch[1]) const normalizedAge = Number.isFinite(ageSeconds) && ageSeconds > 0 ? ageSeconds : 0 freshnessMs = Math.max(0, maxAgeSeconds - normalizedAge) * 1000 } else { const expiresMs = Date.parse(response.headers.get('expires') ?? '') if (Number.isFinite(expiresMs)) freshnessMs = Math.max(0, expiresMs - nowMs) } keyCache = { keys, // The key server's HTTP freshness policy is authoritative. The 24-hour // value is only an upper bound, never a forced cache lifetime. expiresAtMs: nowMs + Math.min(freshnessMs, MAX_KEY_CACHE_MS), } return keys } async function getPublicKey( keyId: string, fetchImpl: typeof fetch, forceRefresh = false, ): Promise { const freshCachedKeys = !forceRefresh && keyCache && keyCache.expiresAtMs > Date.now() ? keyCache.keys : null let keys = freshCachedKeys ?? await fetchPublicKeys(fetchImpl) let key = keys.get(keyId) // Refresh once on a miss only when the first lookup used a still-fresh cache. // A lookup that already fetched the authoritative keyset must not duplicate // the request or fall back to an older removed key. if (!key && freshCachedKeys) { keys = await fetchPublicKeys(fetchImpl) key = keys.get(keyId) } // A newly rotated key can briefly lag behind the callback. Keep this // retryable after one forced refresh so AdMob's delivery retries are not // converted into a permanent 200 acknowledgement and lost reward. if (!key) throw new AdMobSsvError('unknown_key_id', 503) return key } export function parseAdMobCallbackUrl(requestUrl: string): ParsedAdMobCallback { const queryStart = requestUrl.indexOf('?') if (queryStart < 0) throw new AdMobSsvError('missing_query', 400) const rawQuery = requestUrl.slice(queryStart + 1) const signatureMarker = '&signature=' const signatureStart = rawQuery.indexOf(signatureMarker) if (signatureStart < 1) throw new AdMobSsvError('missing_signature', 400) const signedContent = rawQuery.slice(0, signatureStart) const signatureAndKey = rawQuery.slice(signatureStart + 1) const keyMarker = '&key_id=' const keyStart = signatureAndKey.indexOf(keyMarker) if (keyStart < 1) throw new AdMobSsvError('missing_key_id', 400) if (signatureAndKey.indexOf('&', keyStart + keyMarker.length) >= 0) { throw new AdMobSsvError('invalid_parameter_order', 400) } const signatureValue = signatureAndKey.slice('signature='.length, keyStart) const keyId = signatureAndKey.slice(keyStart + keyMarker.length) if (!/^\d+$/.test(keyId)) throw new AdMobSsvError('invalid_key_id', 400) return { signedContent, signature: decodeBase64(decodeURIComponent(signatureValue)), keyId, params: new URLSearchParams(rawQuery), } } export async function verifyAdMobCallback( requestUrl: string, fetchImpl: typeof fetch = fetch, ): Promise { const callback = parseAdMobCallbackUrl(requestUrl) // 리플레이 방지: 동일 transaction_id의 두 번째 검증은 거부한다. const txId = callback.params.get('transaction_id') const nowMs = Date.now() for (const [seenTx, expiresAt] of replaySeenTransactions) { if (expiresAt <= nowMs) replaySeenTransactions.delete(seenTx) } // AdMob sends Unix epoch milliseconds. Reject seconds, microseconds, stale // callbacks, and unsafe numeric values before doing network/key work. const timestampMs = Number(callback.params.get('timestamp')) if ( !Number.isSafeInteger(timestampMs) || timestampMs <= 0 || nowMs - timestampMs >= SSV_MAX_AGE_MS || timestampMs - nowMs >= SSV_MAX_FUTURE_MS ) { throw new AdMobSsvError('stale_timestamp', 400) } const key = await getPublicKey(callback.keyId, fetchImpl) const signature = derEcdsaSignatureToRaw(callback.signature) const valid = await crypto.subtle.verify( { name: 'ECDSA', hash: 'SHA-256' }, key, toArrayBuffer(signature), encoder.encode(callback.signedContent), ) if (!valid) throw new AdMobSsvError('invalid_signature', 400) // Check the local replay cache only after authentication. The transaction is // committed to this cache by the caller *after* durable DB persistence, so a // transient persistence failure remains retryable. The DB unique constraint // is the authoritative cross-isolate exactly-once boundary. if (txId && replaySeenTransactions.has(txId)) { throw new AdMobSsvError('replay_detected', 409) } return callback } export function rememberVerifiedAdMobTransaction(transactionId: string): void { if (!transactionId) return replaySeenTransactions.set(transactionId, Date.now() + SSV_REPLAY_TTL_MS) } export function getAllowedRewardedAdUnitIds(): ReadonlySet { const configuredValues = (Deno.env.get('ADMOB_REWARDED_AD_UNIT_ANDROID') ?? '') .split(',') .map((value) => value.trim()) .filter(Boolean) const configured = new Set() for (const value of configuredValues) { configured.add(value) const numericId = value.includes('/') ? value.slice(value.lastIndexOf('/') + 1) : value if (/^\d+$/.test(numericId)) configured.add(numericId) } const supabaseUrl = Deno.env.get('SUPABASE_URL') ?? '' if (/^https?:\/\/(127\.0\.0\.1|localhost)(:\d+)?(?:\/|$)/.test(supabaseUrl)) { configured.add('5224354917') } return configured }