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

277 lines
10 KiB
TypeScript

import {
AdMobSsvError,
getAllowedRewardedAdUnitIds,
parseAdMobCallbackUrl,
rememberVerifiedAdMobTransaction,
verifyAdMobCallback,
} from './admob-ssv.ts'
function assert(condition: boolean, message: string): asserts condition {
if (!condition) throw new Error(message)
}
function base64(bytes: Uint8Array): string {
let binary = ''
for (const byte of bytes) binary += String.fromCharCode(byte)
return btoa(binary)
}
function base64Url(bytes: Uint8Array): string {
return base64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
}
function derInteger(raw: Uint8Array): Uint8Array {
let firstNonZero = 0
while (firstNonZero < raw.length - 1 && raw[firstNonZero] === 0) firstNonZero += 1
const trimmed = raw.slice(firstNonZero)
const needsLeadingZero = (trimmed[0] & 0x80) !== 0
const value = needsLeadingZero
? Uint8Array.from([0, ...trimmed])
: trimmed
return Uint8Array.from([0x02, value.length, ...value])
}
function rawEcdsaToDer(raw: Uint8Array): Uint8Array {
assert(raw.length === 64, 'test signer must return a P-256 raw signature')
const r = derInteger(raw.slice(0, 32))
const s = derInteger(raw.slice(32))
return Uint8Array.from([0x30, r.length + s.length, ...r, ...s])
}
Deno.test('AdMob callback verifies exact raw query content and rejects tampering', async () => {
const keyPair = await crypto.subtle.generateKey(
{ name: 'ECDSA', namedCurve: 'P-256' },
true,
['sign', 'verify'],
)
const signedContent = [
'ad_network=5450213213286189855',
'ad_unit=ca-app-pub-1234567890123456%2F1234567890',
'reward_amount=50',
'reward_item=cloud_ai_tokens',
`timestamp=${Date.now()}`,
'transaction_id=18fa792de1bca816048293fc71035638',
'user_id=11111111-2222-4333-8444-555555555555',
].join('&')
const rawSignature = new Uint8Array(await crypto.subtle.sign(
{ name: 'ECDSA', hash: 'SHA-256' },
keyPair.privateKey,
new TextEncoder().encode(signedContent),
))
const signature = base64Url(rawEcdsaToDer(rawSignature))
const callbackUrl = `https://example.test/admob?${signedContent}&signature=${signature}&key_id=123`
const publicKey = new Uint8Array(await crypto.subtle.exportKey('spki', keyPair.publicKey))
const keyResponse = JSON.stringify({
keys: [{ keyId: 123, base64: base64(publicKey) }],
})
const fakeFetch: typeof fetch = (_input, _init) => Promise.resolve(new Response(keyResponse, {
status: 200,
headers: { 'Content-Type': 'application/json' },
}))
const verified = await verifyAdMobCallback(callbackUrl, fakeFetch)
assert(verified.params.get('reward_amount') === '50', 'verified reward amount must parse')
assert(verified.params.get('ad_unit')?.includes('/') === true, 'percent encoding must decode after verification')
let tamperError: unknown
try {
await verifyAdMobCallback(callbackUrl.replace('reward_amount=50', 'reward_amount=51'), fakeFetch)
} catch (error) {
tamperError = error
}
assert(tamperError instanceof AdMobSsvError, 'tampered callback must fail')
assert(tamperError.code === 'invalid_signature', 'tampering must be reported as invalid signature')
})
Deno.test('AdMob callback requires an epoch-millisecond timestamp within the freshness window', async () => {
const keyPair = await crypto.subtle.generateKey(
{ name: 'ECDSA', namedCurve: 'P-256' },
true,
['sign', 'verify'],
)
const publicKey = new Uint8Array(await crypto.subtle.exportKey('spki', keyPair.publicKey))
const fakeFetch: typeof fetch = () => Promise.resolve(new Response(JSON.stringify({
keys: [{ keyId: 456, base64: base64(publicKey) }],
}), { status: 200, headers: { 'Content-Type': 'application/json' } }))
async function signedUrl(timestamp: string, transactionId: string): Promise<string> {
const signedContent = [
'ad_network=5450213213286189855',
'ad_unit=1234567890',
'reward_amount=50',
'reward_item=cloud_ai_tokens',
`timestamp=${timestamp}`,
`transaction_id=${transactionId}`,
'user_id=11111111-2222-4333-8444-555555555555',
].join('&')
const rawSignature = new Uint8Array(await crypto.subtle.sign(
{ name: 'ECDSA', hash: 'SHA-256' },
keyPair.privateKey,
new TextEncoder().encode(signedContent),
))
const signature = base64Url(rawEcdsaToDer(rawSignature))
return `https://example.test/admob?${signedContent}&signature=${signature}&key_id=456`
}
await verifyAdMobCallback(
await signedUrl(String(Date.now()), '18fa792de1bca816048293fc71035639'),
fakeFetch,
)
for (const [timestamp, transactionId] of [
[String(Math.floor(Date.now() / 1000)), '18fa792de1bca816048293fc71035640'],
[String(Date.now() - 61 * 60 * 1000), '18fa792de1bca816048293fc71035641'],
[String(Date.now() + 61 * 60 * 1000), '18fa792de1bca816048293fc71035642'],
] as const) {
let error: unknown
try {
await verifyAdMobCallback(await signedUrl(timestamp, transactionId), fakeFetch)
} catch (candidate) {
error = candidate
}
assert(error instanceof AdMobSsvError, 'invalid timestamp must fail closed')
assert(error.code === 'stale_timestamp', 'invalid timestamp must report stale_timestamp')
}
})
Deno.test('AdMob callback requires signature and key id to be the final ordered parameters', () => {
for (const url of [
'https://example.test/admob?reward_amount=50',
'https://example.test/admob?reward_amount=50&signature=abc',
'https://example.test/admob?reward_amount=50&signature=abc&key_id=123&extra=1',
]) {
let error: unknown
try {
parseAdMobCallbackUrl(url)
} catch (candidate) {
error = candidate
}
assert(error instanceof AdMobSsvError, 'invalid parameter order must fail closed')
}
})
Deno.test('AdMob replay memory is committed only after durable reward persistence', async () => {
const keyPair = await crypto.subtle.generateKey(
{ name: 'ECDSA', namedCurve: 'P-256' },
true,
['sign', 'verify'],
)
const transactionId = '18fa792de1bca816048293fc71035643'
const signedContent = [
'ad_network=5450213213286189855',
'ad_unit=1234567890',
'reward_amount=50',
'reward_item=cloud_ai_tokens',
`timestamp=${Date.now()}`,
`transaction_id=${transactionId}`,
'user_id=11111111-2222-4333-8444-555555555555',
].join('&')
const rawSignature = new Uint8Array(await crypto.subtle.sign(
{ name: 'ECDSA', hash: 'SHA-256' },
keyPair.privateKey,
new TextEncoder().encode(signedContent),
))
const signature = base64Url(rawEcdsaToDer(rawSignature))
const callbackUrl = `https://example.test/admob?${signedContent}&signature=${signature}&key_id=789`
const publicKey = new Uint8Array(await crypto.subtle.exportKey('spki', keyPair.publicKey))
const fakeFetch: typeof fetch = () => Promise.resolve(new Response(JSON.stringify({
keys: [{ keyId: 789, base64: base64(publicKey) }],
}), { status: 200, headers: { 'Content-Type': 'application/json' } }))
await verifyAdMobCallback(callbackUrl, fakeFetch)
await verifyAdMobCallback(callbackUrl, fakeFetch)
rememberVerifiedAdMobTransaction(transactionId)
let error: unknown
try {
await verifyAdMobCallback(callbackUrl, fakeFetch)
} catch (candidate) {
error = candidate
}
assert(error instanceof AdMobSsvError, 'committed transaction must reject replay')
assert(error.code === 'replay_detected', 'committed transaction must report replay_detected')
})
Deno.test('AdMob key cache obeys max-age=0 and rejects a removed signing key', async () => {
const oldPair = await crypto.subtle.generateKey(
{ name: 'ECDSA', namedCurve: 'P-256' },
true,
['sign', 'verify'],
)
const replacementPair = await crypto.subtle.generateKey(
{ name: 'ECDSA', namedCurve: 'P-256' },
true,
['sign', 'verify'],
)
const oldPublicKey = new Uint8Array(await crypto.subtle.exportKey('spki', oldPair.publicKey))
const replacementPublicKey = new Uint8Array(
await crypto.subtle.exportKey('spki', replacementPair.publicKey),
)
let oldKeyIsAuthoritative = true
let fetchCount = 0
const fakeFetch: typeof fetch = () => {
fetchCount += 1
const record = oldKeyIsAuthoritative
? { keyId: 901, base64: base64(oldPublicKey) }
: { keyId: 902, base64: base64(replacementPublicKey) }
return Promise.resolve(new Response(JSON.stringify({ keys: [record] }), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=0',
Age: '0',
},
}))
}
async function oldKeyCallback(transactionId: string): Promise<string> {
const signedContent = [
'ad_network=5450213213286189855',
'ad_unit=1234567890',
'reward_amount=50',
'reward_item=cloud_ai_tokens',
`timestamp=${Date.now()}`,
`transaction_id=${transactionId}`,
'user_id=11111111-2222-4333-8444-555555555555',
].join('&')
const rawSignature = new Uint8Array(await crypto.subtle.sign(
{ name: 'ECDSA', hash: 'SHA-256' },
oldPair.privateKey,
new TextEncoder().encode(signedContent),
))
const signature = base64Url(rawEcdsaToDer(rawSignature))
return `https://example.test/admob?${signedContent}&signature=${signature}&key_id=901`
}
await verifyAdMobCallback(
await oldKeyCallback('18fa792de1bca816048293fc71035644'),
fakeFetch,
)
oldKeyIsAuthoritative = false
let error: unknown
try {
await verifyAdMobCallback(
await oldKeyCallback('18fa792de1bca816048293fc71035645'),
fakeFetch,
)
} catch (candidate) {
error = candidate
}
assert(fetchCount === 2, 'max-age=0 must force one authoritative fetch per verification')
assert(error instanceof AdMobSsvError, 'removed signing key must fail closed')
assert(error.code === 'unknown_key_id', 'removed signing key must report unknown_key_id')
assert(error.status === 503, 'key propagation miss must remain retryable')
})
Deno.test('AdMob callback allowlist normalizes full mobile unit ids to numeric SSV ids', () => {
Deno.env.set('ADMOB_REWARDED_AD_UNIT_ANDROID', 'ca-app-pub-1234567890123456/9876543210')
Deno.env.set('SUPABASE_URL', 'https://project.supabase.co')
try {
const allowed = getAllowedRewardedAdUnitIds()
assert(allowed.has('9876543210'), 'numeric callback ad_unit must be allowlisted')
assert(allowed.has('ca-app-pub-1234567890123456/9876543210'), 'configured full id must remain accepted')
} finally {
Deno.env.delete('ADMOB_REWARDED_AD_UNIT_ANDROID')
Deno.env.delete('SUPABASE_URL')
}
})