281 lines
10 KiB
TypeScript
281 lines
10 KiB
TypeScript
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<string, CryptoKey>
|
|
}
|
|
|
|
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<string, number>()
|
|
|
|
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<Map<string, CryptoKey>> {
|
|
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<string, CryptoKey>()
|
|
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<CryptoKey> {
|
|
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<ParsedAdMobCallback> {
|
|
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<string> {
|
|
const configuredValues = (Deno.env.get('ADMOB_REWARDED_AD_UNIT_ANDROID') ?? '')
|
|
.split(',')
|
|
.map((value) => value.trim())
|
|
.filter(Boolean)
|
|
const configured = new Set<string>()
|
|
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
|
|
}
|