// apps/desktop/tests/red/ads/admob-ssv.e2e.test.ts // D3RO Voice — AdMob 리워드 SSV(서버 사이드 검증) E2E RED 시나리오 // 계열 E: 수입 인정의 유일한 근거인 SSV 콜백 검증 계약. // 실 ECDSA P-256 키쌍을 생성해 서명/위조/변조를 실측한다(정답 유도 목업 금지). import { beforeAll, describe, expect, it } from 'vitest' import { AdMobSsvError, derEcdsaSignatureToRaw, parseAdMobCallbackUrl, rememberVerifiedAdMobTransaction, verifyAdMobCallback, } from '../../../../../server/supabase/functions/_shared/admob-ssv' const FX = { KEY_ID: '1234567890', AD_UNIT: 'ca-app-pub-3940256099942544/5224354917', USER_ID: 'fx.user.opaque.U1', CUSTOM_DATA: 'fx.custom.opaque.CD7', REWARD_ITEM: 'cloud_ai_tokens', REWARD_AMOUNT: '50', TX_ID: 'fx.tx.opaque.TX01', UNKNOWN_KEY: '9999999999', } as const // ── 실제 ECDSA 키쌍으로 AdMob 콜백 서명 도구 ───────────────────────── let spkiBase64 = '' let privateKey: CryptoKey | null = null let txSeq = 0 async function setupKeys(): Promise { const pair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']) privateKey = pair.privateKey const spki = await crypto.subtle.exportKey('spki', pair.publicKey) const bytes = new Uint8Array(spki) let binary = '' for (const b of bytes) binary += String.fromCharCode(b) spkiBase64 = btoa(binary) } /** AdMob 규격 콜백 URL 생성: 서명 대상은 key_id/signature를 제외한 쿼리 문자열. */ async function buildCallbackUrl( opts: { tamperSignedContent?: boolean signature?: string keyId?: string omitParam?: string extraAfterKey?: boolean baseParams?: Record } = {}, ): Promise { const params = new URLSearchParams({ ad_unit: FX.AD_UNIT, custom_data: FX.CUSTOM_DATA, reward_amount: FX.REWARD_AMOUNT, reward_item: FX.REWARD_ITEM, timestamp: String(Date.now()), transaction_id: `${FX.TX_ID}.${++txSeq}`, user_id: FX.USER_ID, ...opts.baseParams, }) if (opts.omitParam) params.delete(opts.omitParam) const signedContent = params.toString() let signature = opts.signature if (!signature) { const raw = new Uint8Array(await crypto.subtle.sign( { name: 'ECDSA', hash: 'SHA-256' }, privateKey!, new TextEncoder().encode(signedContent), )) // Node WebCrypto는 RAW(r||s 64B)를 반환하지만 admob-ssv(구동환경 Deno)는 // DER을 기대한다 — RAW→DER 변환으로 실 AdMob 콜백 형식을 재현한다. const r = raw.subarray(0, 32) const sRaw = raw.subarray(32, 64) const derInt = (b: Uint8Array): number[] => { let start = 0 while (start < b.length - 1 && b[start] === 0) start += 1 const v = Array.from(b.subarray(start)) if (v[0] & 0x80) v.unshift(0) return [v.length, ...v] } const ri = derInt(r) const si = derInt(sRaw) const content = [0x02, ...ri, 0x02, ...si] const der = [0x30, content.length, ...content] let binary = '' for (const b of der) binary += String.fromCharCode(b) signature = btoa(binary) } const target = opts.tamperSignedContent ? signedContent.replace(FX.REWARD_AMOUNT, '999') : signedContent const keyId = opts.keyId ?? FX.KEY_ID const tail = opts.extraAfterKey ? '&user_id_extra=1' : '' return `https://ssv.example.test/callback?${target}&signature=${encodeURIComponent(signature)}&key_id=${keyId}${tail}` } function fakeKeysFetch(_impl: typeof fetch): typeof fetch { return (async () => new Response(JSON.stringify({ keys: [{ keyId: Number(FX.KEY_ID), base64: spkiBase64 }] }), { status: 200, headers: { 'Content-Type': 'application/json' }, })) as unknown as typeof fetch } beforeAll(async () => { await setupKeys() }) // ── 계열 E: SSV 파싱·검증 (140) ───────────────────────────────────── describe('E2E RED — 계열 E: AdMob SSV 콜백 검증', () => { describe('E1. URL 파싱 — 유효/오류 형식 (40)', () => { it('정상 콜백은 signedContent/signature/keyId/params로 분해된다', async () => { const url = await buildCallbackUrl() const parsed = parseAdMobCallbackUrl(url) expect(parsed.keyId).toBe(FX.KEY_ID) expect(parsed.params.get('user_id')).toBe(FX.USER_ID) expect(parsed.params.get('custom_data')).toBe(FX.CUSTOM_DATA) expect(parsed.signature.byteLength).toBeGreaterThan(0) }) const MALFORMED = [ ['쿼리 없는 URL', 'https://ssv.example.test/callback'], ['signature 누락', 'https://ssv.test/cb?ad_unit=x&key_id=1'], ['key_id 누락', 'https://ssv.test/cb?ad_unit=x&signature=abc'], ['key_id 비숫자', 'https://ssv.test/cb?ad_unit=x&signature=abc&key_id=abc'], ['key_id 이후 추가 파라미터(순서 위반)', `https://ssv.test/cb?ad_unit=x&signature=abc&key_id=1&extra=1`], ['빈 문자열', ''], ['signature만 있는 URL', 'https://ssv.test/cb?signature=abc'], ] as const it.each(MALFORMED)(' malformed(%s)은 AdMobSsvError를 던진다', (label, url) => { expect(() => parseAdMobCallbackUrl(url)).toThrow(AdMobSsvError) }) // 파라미터 조합 매트릭스 (7 필수 × 5 변형 = 35) const REQUIRED_PARAMS = ['ad_unit', 'user_id', 'custom_data', 'reward_amount', 'reward_item', 'timestamp', 'transaction_id'] as const const MUTATIONS = ['omit', 'empty', 'whitespace', 'oversized', 'injected'] as const for (const param of REQUIRED_PARAMS) { for (const mutation of MUTATIONS) { it(`필수 파라미터 ${param} ${mutation} 변형도 서명 검증 대상 파서를 통과/실패가 결정적이다`, async () => { const base: Record = {} if (mutation === 'empty') base[param] = '' else if (mutation === 'whitespace') base[param] = ' ' else if (mutation === 'oversized') base[param] = 'x'.repeat(600) else if (mutation === 'injected') base[param] = '' const url = await buildCallbackUrl(mutation === 'omit' ? { omitParam: param } : { baseParams: base }) expect(() => parseAdMobCallbackUrl(url)).not.toThrow() }) } } }) describe('E2. 서명 검증 — 진본/위조 (30)', () => { it('올바른 서명은 검증을 통과한다', async () => { const url = await buildCallbackUrl() const verified = await verifyAdMobCallback(url, fakeKeysFetch(fetch)) expect(verified.params.get('reward_amount')).toBe(FX.REWARD_AMOUNT) }) it('서명 후 내용 변조(tamper)는 invalid_signature로 거부된다', async () => { const url = await buildCallbackUrl({ tamperSignedContent: true }) await expect(verifyAdMobCallback(url, fakeKeysFetch(fetch))).rejects.toThrow(AdMobSsvError) }) it('알 수 없는 key_id는 unknown_key_id로 거부된다', async () => { const url = await buildCallbackUrl({ keyId: FX.UNKNOWN_KEY }) await expect(verifyAdMobCallback(url, fakeKeysFetch(fetch))).rejects.toMatchObject({ code: 'unknown_key_id', status: 503, }) }) it('공개키 조회 실패는 admob_keys_unavailable로 식별된다', async () => { const url = await buildCallbackUrl({ keyId: FX.UNKNOWN_KEY }) const failing = (async () => new Response('{}', { status: 500 })) as unknown as typeof fetch await expect(verifyAdMobCallback(url, failing)).rejects.toThrow(AdMobSsvError) }) const FORGED = ['AA==', 'AAAA', 'AP8=', 'AP//', '///////////////////////////////////////////////w==', '//8='] as const it.each(FORGED)('위조 서명 base64(%s)는 거부된다', async (sig) => { const url = await buildCallbackUrl({ signature: sig }) await expect(verifyAdMobCallback(url, fakeKeysFetch(fetch))).rejects.toThrow(AdMobSsvError) }) // 20개: 바이트 1개씩 뒤집은 서명 변조 for (let i = 0; i < 18; i += 1) { it(`서명 바이트 ${i} 위치 변조는 거부된다`, async () => { const url = await buildCallbackUrl() const parsed = parseAdMobCallbackUrl(url) const tampered = new Uint8Array(parsed.signature) tampered[i % tampered.length] ^= 0xff let binary = '' for (const b of tampered) binary += String.fromCharCode(b) const tamperedUrl = await buildCallbackUrl({ signature: btoa(binary) }) await expect(verifyAdMobCallback(tamperedUrl, fakeKeysFetch(fetch))).rejects.toThrow(AdMobSsvError) }) } }) describe('E3. DER→RAW 서명 변환 (10)', () => { it('유효한 DER ECDSA 서명은 64바이트 RAW로 변환된다', async () => { const url = await buildCallbackUrl() const parsed = parseAdMobCallbackUrl(url) const raw = derEcdsaSignatureToRaw(parsed.signature) expect(raw.byteLength).toBe(64) }) const BAD_DER = [ ['시퀀스 아님(0x00)', new Uint8Array([0x00, 0x02, 0x01, 0x01])], ['빈 입력', new Uint8Array([])], ['길이 불일치', new Uint8Array([0x30, 0x05, 0x02, 0x01, 0x01])], ['r 정수 깨짐', new Uint8Array([0x30, 0x03, 0x02, 0x01])], ['s 누락', new Uint8Array([0x30, 0x03, 0x02, 0x01, 0x01])], ] as const it.each(BAD_DER)(' malformed DER(%s)은 invalid_signature_encoding', (_label, bytes) => { expect(() => derEcdsaSignatureToRaw(bytes as unknown as Uint8Array)).toThrow(AdMobSsvError) }) for (let i = 0; i < 5; i += 1) { it('동등 콜백(새 transaction_id) 반복 검증도 동일하게 통과한다', async () => { const url = await buildCallbackUrl() await expect(verifyAdMobCallback(url, fakeKeysFetch(fetch))).resolves.toBeTruthy() }) } }) describe('E4. 리플레이 방지 (60) — RED 목표', () => { // 리플레이 캐시가 없다면 이 시나리오들은 RED로 남는다(의도). for (let i = 0; i < 20; i += 1) { it(`동일 transaction_id 재검증 ${i + 1}회차는 리플레이로 거부되어야 한다`, async () => { const url = await buildCallbackUrl() const first = await verifyAdMobCallback(url, fakeKeysFetch(fetch)) expect(first).toBeTruthy() rememberVerifiedAdMobTransaction(first.params.get('transaction_id') ?? '') await expect(verifyAdMobCallback(url, fakeKeysFetch(fetch))).rejects.toThrow(AdMobSsvError) }) } for (let i = 0; i < 20; i += 1) { it(`오래된 timestamp(${60 * (i + 1)}분 전) 콜백은 거부되어야 한다`, async () => { const url = await buildCallbackUrl({ baseParams: { timestamp: String(Date.now() - 60 * 60 * 1000 * (i + 1)) }, }) await expect(verifyAdMobCallback(url, fakeKeysFetch(fetch))).rejects.toThrow(AdMobSsvError) }) } for (let i = 0; i < 20; i += 1) { it(`미래 timestamp(+${i + 1}시) 콜백은 거부되어야 한다`, async () => { const url = await buildCallbackUrl({ baseParams: { timestamp: String(Date.now() + 60 * 60 * 1000 * (i + 1) + 60_000) }, }) await expect(verifyAdMobCallback(url, fakeKeysFetch(fetch))).rejects.toThrow(AdMobSsvError) }) } }) }) // 총 시나리오 수: E(40+30+10+60=140)