package usage import ( "context" "crypto/ecdsa" "crypto/sha256" "crypto/x509" "encoding/base64" "encoding/json" "encoding/pem" "errors" "fmt" "net/http" "net/url" "strings" "sync" "time" ) // AdVerifyRequest carries the inputs an AdVerifier needs to authenticate a // rewarded-ad receipt. type AdVerifyRequest struct { // UserID is the authenticated user requesting the unlock. UserID int64 // DeviceID is the device UUID that played the ad. DeviceID string // AdToken is the provider's server-side verification receipt. For AdMob // SSV this is the full callback query string (everything after the '?'). AdToken string // ExpectedCustomData, when non-empty, must match the receipt's custom_data // field (binds the receipt to this user). ExpectedCustomData string } // AdVerifier authenticates a rewarded-ad receipt with the ad network. // Implementations must be safe for concurrent use. type AdVerifier interface { // Verify returns nil if the receipt is genuine and bound to the request. Verify(ctx context.Context, req AdVerifyRequest) error // Provider names the ad network (e.g. "admob", "unity"). Provider() string } // -------------------------------------------------------------------------- // AdMob Server-Side Verification (SSV) // -------------------------------------------------------------------------- // DefaultAdMobKeyServerURL is Google's public ECDSA key endpoint for AdMob SSV. const DefaultAdMobKeyServerURL = "https://www.gstatic.com/admob/reward/verifier-keys.json" // AdMobVerifier verifies AdMob SSV callbacks. It fetches and caches Google's // ECDSA public keys, then checks the ECDSA-SHA256 signature over the callback // content, that the receipt's custom_data matches the user, and (optionally) // that the ad_unit is one we recognise. type AdMobVerifier struct { keyServerURL string httpClient *http.Client allowedAdUnits map[string]struct{} keyTTL time.Duration mu sync.RWMutex keys map[string]*ecdsa.PublicKey fetchedAt time.Time } // NewAdMobVerifier creates an AdMobVerifier. keyServerURL empty → the default // Google endpoint. keyTTL <= 0 → 12h. allowedAdUnits empty → any ad unit is // accepted (only the signature and custom_data are enforced). func NewAdMobVerifier(keyServerURL string, httpClient *http.Client, keyTTL time.Duration, allowedAdUnits []string) *AdMobVerifier { if keyServerURL == "" { keyServerURL = DefaultAdMobKeyServerURL } if httpClient == nil { httpClient = &http.Client{Timeout: 5 * time.Second} } if keyTTL <= 0 { keyTTL = 12 * time.Hour } allowed := make(map[string]struct{}, len(allowedAdUnits)) for _, u := range allowedAdUnits { allowed[u] = struct{}{} } return &AdMobVerifier{ keyServerURL: keyServerURL, httpClient: httpClient, allowedAdUnits: allowed, keyTTL: keyTTL, } } // Provider implements AdVerifier. func (v *AdMobVerifier) Provider() string { return "admob" } // errAdMobVerify is returned for any receipt that fails authentication. var errAdMobVerify = errors.New("admob: receipt verification failed") // Verify implements AdVerifier for AdMob SSV. // // AdMob signs the callback by computing ECDSA-SHA256 over the query string up // to (but excluding) "&signature="; the signature (web-safe base64, ASN.1 DER) // and key_id follow. See Google's SSV documentation. func (v *AdMobVerifier) Verify(ctx context.Context, req AdVerifyRequest) error { raw := strings.TrimPrefix(req.AdToken, "?") if raw == "" { return errAdMobVerify } // Content to verify = everything before "&signature=". sigMarker := strings.Index(raw, "&signature=") if sigMarker < 0 { return errAdMobVerify } content := raw[:sigMarker] params, err := url.ParseQuery(raw) if err != nil { return errAdMobVerify } sigB64 := params.Get("signature") keyID := params.Get("key_id") if sigB64 == "" || keyID == "" { return errAdMobVerify } // Bind the receipt to the user / ad unit. if req.ExpectedCustomData != "" && params.Get("custom_data") != req.ExpectedCustomData { return errAdMobVerify } if len(v.allowedAdUnits) > 0 { if _, ok := v.allowedAdUnits[params.Get("ad_unit")]; !ok { return errAdMobVerify } } sig, err := base64.RawURLEncoding.DecodeString(sigB64) if err != nil { // Some senders include padding; fall back to standard URL encoding. sig, err = base64.URLEncoding.DecodeString(sigB64) if err != nil { return errAdMobVerify } } pub, err := v.publicKey(ctx, keyID) if err != nil { return err } digest := sha256.Sum256([]byte(content)) if !ecdsa.VerifyASN1(pub, digest[:], sig) { return errAdMobVerify } return nil } // publicKey returns the cached ECDSA public key for keyID, refreshing the key // set from the server when the cache is empty, stale, or missing the key. func (v *AdMobVerifier) publicKey(ctx context.Context, keyID string) (*ecdsa.PublicKey, error) { v.mu.RLock() pub, ok := v.keys[keyID] fresh := time.Since(v.fetchedAt) < v.keyTTL v.mu.RUnlock() if ok && fresh { return pub, nil } if err := v.refreshKeys(ctx); err != nil { // Serve a stale cached key rather than fail outright on a transient // fetch error. if ok { return pub, nil } return nil, err } v.mu.RLock() pub, ok = v.keys[keyID] v.mu.RUnlock() if !ok { return nil, fmt.Errorf("admob: unknown key_id %q", keyID) } return pub, nil } // admobKeySet mirrors the JSON returned by the AdMob verifier-keys endpoint. type admobKeySet struct { Keys []struct { KeyID json.Number `json:"keyId"` PEM string `json:"pem"` Base64 string `json:"base64"` } `json:"keys"` } // refreshKeys fetches and parses the public key set from the key server. func (v *AdMobVerifier) refreshKeys(ctx context.Context) error { httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, v.keyServerURL, nil) if err != nil { return fmt.Errorf("admob refreshKeys: %w", err) } resp, err := v.httpClient.Do(httpReq) if err != nil { return fmt.Errorf("admob refreshKeys: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("admob refreshKeys: status %d", resp.StatusCode) } var ks admobKeySet if err := json.NewDecoder(resp.Body).Decode(&ks); err != nil { return fmt.Errorf("admob refreshKeys decode: %w", err) } parsed := make(map[string]*ecdsa.PublicKey, len(ks.Keys)) for _, k := range ks.Keys { pub, err := parseECDSAPublicKey(k.PEM) if err != nil { continue // skip unparseable keys rather than failing the whole set } parsed[k.KeyID.String()] = pub } if len(parsed) == 0 { return errors.New("admob refreshKeys: no usable keys") } v.mu.Lock() v.keys = parsed v.fetchedAt = time.Now() v.mu.Unlock() return nil } // parseECDSAPublicKey parses a PEM-encoded PKIX ECDSA public key. func parseECDSAPublicKey(pemStr string) (*ecdsa.PublicKey, error) { block, _ := pem.Decode([]byte(pemStr)) if block == nil { return nil, errors.New("admob: invalid PEM") } anyKey, err := x509.ParsePKIXPublicKey(block.Bytes) if err != nil { return nil, fmt.Errorf("admob: parse pkix: %w", err) } pub, ok := anyKey.(*ecdsa.PublicKey) if !ok { return nil, errors.New("admob: not an ECDSA key") } return pub, nil } // -------------------------------------------------------------------------- // Unity Ads (interface placeholder) // -------------------------------------------------------------------------- // UnityVerifier is a placeholder for Unity Ads SSV; the interface is wired now // and the verification logic will be implemented when Unity is integrated. type UnityVerifier struct{} // Provider implements AdVerifier. func (UnityVerifier) Provider() string { return "unity" } // errUnityUnimplemented signals that Unity SSV is not yet available. var errUnityUnimplemented = errors.New("unity: SSV verification not implemented") // Verify implements AdVerifier. func (UnityVerifier) Verify(context.Context, AdVerifyRequest) error { return errUnityUnimplemented }