d3ro-voice/apps/desktop/tests/red/ads/admob-ssv.e2e.test.ts
2026-08-29 18:33:45 +09:00

258 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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<void> {
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<string, string>
} = {},
): Promise<string> {
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<string, string> = {}
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] = '<script>alert(1)</script>'
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)