277 lines
10 KiB
TypeScript
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')
|
|
}
|
|
})
|