feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
277
server/supabase/functions/_shared/admob-ssv.test.ts
Normal file
277
server/supabase/functions/_shared/admob-ssv.test.ts
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
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')
|
||||
}
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue