d3ro-voice/server/supabase/functions/_shared/admob-ssv.ts
2026-08-29 18:33:45 +09:00

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
}