Tantangan Arsitektur Validasi IAP Multi-Store

Mendukung beberapa channel distribusi aplikasi seperti Google Play Store, Huawei AppGallery, Samsung Galaxy Store, atau distribusi APK langsung memerlukan pipeline verifikasi pembelian in-app (IAP) yang ketat. Mengandalkan validasi sisi klien merupakan celah keamanan fatal karena klien dapat dimanipulasi melalui instrumen reverse engineering seperti Frida atau APK patching.

Backend bertanggung jawab penuh sebagai single source of truth untuk menentukan keabsahan transaksi sebelum memberikan hak akses digital (entitlement) kepada user. Celah umum pada implementasi multi-store mencakup:

  • Replay Attack: Mengirimkan receipt valid yang sama berkali-kali untuk akun berbeda.
  • Cross-Store Receipt Injection: Mengirim payload receipt dari store A ke endpoint store B yang memiliki mekanisme parsing lebih longgar.
  • Signature Bypassing: Melewatkan verifikasi kriptografis lokal dan langsung mempercayai data JSON parsed dari request client.
  • Credential Leakage: Menggunakan konfigurasi environment tunggal untuk seluruh secret store tanpa isolasi.

Pemisahan Secret Key dan Strategi Registry Kanal Distribusi

Hindari pemrosesan receipt menggunakan endpoint generic tanpa segmentasi store yang eksplisit. Buat interface atau provider registry di mana setiap channel distribusi memiliki credentials, public key, dan endpoint validasi independen.

// internal/iap/provider.go
package iap

type Channel string

const (
    ChannelGooglePlay Channel = "google_play"
    ChannelHuawei     Channel = "huawei_appgallery"
)

type VerificationPayload struct {
    Channel       Channel `json:"channel"`
    UserID        string  `json:"user_id"`
    ReceiptData   string  `json:"receipt_data"`   // JSON payload dari store
    Signature     string  `json:"signature"`      // Base64 signature
    TransactionID string  `json:"transaction_id"`
    Nonce         string  `json:"nonce"`          // Unique token dari client per order
}

type Provider interface {
    Verify(payload VerificationPayload) error
}

Simpan public key dan credential service account di secure storage (seperti HashiCorp Vault, AWS Secrets Manager, atau environment variable terisolasi). Jangan gunakan format global fallback jika nama channel tidak cocok.

Verifikasi Kriptografis Lokal Menggunakan Go Standard Library

Google Play dan beberapa store pihak ketiga menyediakan tanda tangan digital berbasis RSA (PKCS#1 v1.5 dengan SHA-256) bersama payload receipt data. Verifikasi signature lokal ini penting dilakukan sebelum memanggil API eksternal untuk menghemat quota rate limit dan menyaring request palsu secara instan.

Berikut implementasi verifikasi signature RSA-SHA256 tanpa library eksternal menggunakan pustaka standar crypto/rsa, crypto/x509, dan crypto/sha256:

package iap

import (
    "crypto"
    "crypto/rsa"
    "crypto/sha256"
    "crypto/x509"
    "encoding/base64"
    "errors"
    "fmt"
)

func VerifyRSASignature(base64PublicKey string, data []byte, base64Signature string) error {
    // 1. Decode public key dari format Base64 (X.509 SubjectPublicKeyInfo)
    pubKeyDer, err := base64.StdEncoding.DecodeString(base64PublicKey)
    if err != nil {
        return fmt.Errorf("gagal decode public key: %w", err)
    }

    parsedKey, err := x509.ParsePKIXPublicKey(pubKeyDer)
    if err != nil {
        return fmt.Errorf("gagal parsing PKIX public key: %w", err)
    }

    rsaPubKey, ok := parsedKey.(*rsa.PublicKey)
    if !ok {
        return errors.New("public key bukan tipe RSA yang valid")
    }

    // 2. Decode signature
    sigBytes, err := base64.StdEncoding.DecodeString(base64Signature)
    if err != nil {
        return fmt.Errorf("gagal decode signature: %w", err)
    }

    // 3. Hash data mentah menggunakan SHA-256
    hashed := sha256.Sum256(data)

    // 4. Verifikasi cryptographic signature dengan PKCS#1 v1.5
    err = rsa.VerifyPKCS1v15(rsaPubKey, crypto.SHA256, hashed[:], sigBytes)
    if err != nil {
        return errors.New("signature verification failed: payload tidak otentik")
    }

    return nil
}

Pencegahan Replay Attack Menggunakan Nonce dan Idempotency

Verifikasi signature yang sukses hanya membuktikan bahwa receipt dikeluarkan oleh store yang sah, bukan membuktikan bahwa receipt belum pernah digunakan. Penyerang dapat menyadap network request dan mengulang pengiriman receipt yang sama ke akun yang berbeda.

Terapkan dua lapis pertahanan idempotensi pada tingkat database:

  1. Client-Generated Nonce: Sebelum client menginisiasi alur pembayaran di SDK store, backend menghasilkan nonce (UUID v4 atau string kriptografis unik) yang dikaitkan dengan user_id dan item ID ke dalam database dengan status PENDING. Nonce ini disisipkan ke parameter obfuscatedAccountId atau developerPayload pada SDK store.
  2. Unique Constraint pada Transaction ID: Kolom database untuk (channel, transaction_id) harus memiliki UNIQUE constraint.
CREATE TABLE iap_transactions (
    id BIGSERIAL PRIMARY KEY,
    user_id VARCHAR(64) NOT NULL,
    channel VARCHAR(32) NOT NULL,
    transaction_id VARCHAR(255) NOT NULL,
    product_id VARCHAR(128) NOT NULL,
    purchase_state VARCHAR(32) NOT NULL,
    raw_payload JSONB NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    CONSTRAINT uq_channel_tx UNIQUE (channel, transaction_id)
);

Saat pemrosesan transaksi di backend, gunakan transaksi database dengan level isolasi yang tepat atau gunakan pola INSERT ... ON CONFLICT DO NOTHING:

// Eksekusi insert dengan pengecekan duplikasi langsung di database
query := `
    INSERT INTO iap_transactions (user_id, channel, transaction_id, product_id, purchase_state, raw_payload)
    VALUES ($1, $2, $3, $4, $5, $6)
    ON CONFLICT (channel, transaction_id) DO NOTHING
    RETURNING id;
`
var insertedID int64
err := db.QueryRowContext(ctx, query, userID, channel, txID, productID, state, rawJSON).Scan(&insertedID)
if errors.Is(err, sql.ErrNoRows) {
    return errors.New("transaksi telah diproses sebelumnya (replay detected)")
}

Validasi State Server-to-Server dan Penanganan Refund

Verifikasi signature lokal tidak menjamin bahwa transaksi tidak dibatalkan, di-chargeback, atau di-refund beberapa detik setelah transaksi selesai. Backend harus melakukan validasi tahap kedua secara langsung ke API store (Server-to-Server / S2S):

  • Google Play Developer API: Panggil endpoint purchases.products.get untuk memeriksa status purchaseState == 0 (Purchased) dan memanggil purchases.products.acknowledge dalam batas waktu 3 hari agar pembelian tidak di-refund otomatis oleh Google.
  • Store Alternatif: Lakukan integrasi webhook Server Notification untuk mendeteksi pembatalan lisensi atau refund secara real-time.
  • Validasi Developer Payload: Pastikan metadata obfuscatedExternalAccountId yang dikembalikan oleh server store cocok dengan ID user internal yang mengajukan validasi receipt. Ini mencegah user A membeli item secara legal lalu receipt-nya diklaim oleh user B.

Checklist Audit Keamanan

  1. Kunci publik store dimuat saat bootstrap aplikasi, bukan dibaca ulang per request dari file yang tidak terproteksi.
  2. Database constraint menjamin channel + transaction_id unik.
  3. Semua item yang memerlukan entitlement diverifikasi status acknowledgement-nya via S2S.
  4. Log transaksi tidak menyimpan token auth sensitif atau PII user tanpa hashing/masking.
  5. Webhook notifikasi dari store pihak ketiga memverifikasi HMAC secret token yang dikirimkan melalui HTTP header.