Kerentanan Fatal Validasi Sisi Klien

Mempercayai status pembelian langsung dari perangkat mobile membuka celah manipulasi transaksi. Pada perangkat iOS yang di-jailbreak, penyerang dapat memanfaatkan runtime manipulation tool (seperti Frida) untuk mem-bypass method SKPaymentTransactionObserver atau memalsukan respons StoreKit. Penyerang juga dapat melakukan man-in-the-middle (MITM) jika aplikasi tidak menerapkan SSL pinning yang ketat.

Metode legacy Apple verifyReceipt via endpoint https://buy.itunes.apple.com/verifyReceipt berstatus deprecated. Endpoint tersebut rentan terhadap receipt-forwarding attack jika backend tidak memeriksa relasi antara user ID lokal dan data transaksi. Standar industri saat ini adalah menggunakan StoreKit 2 dan App Store Server API, di mana transaksi dikirimkan sebagai payload JSON Web Signature (JWS) yang diverifikasi integritas kriptografisnya di backend.

Arsitektur Validasi JWS StoreKit 2

Saat transaksi berhasil di perangkat iOS, StoreKit 2 menyediakan signedTransactionInfo dalam format JWS (tiga segmen Base64URL: Header, Payload, Signature). Backend memiliki dua metode untuk memvalidasi transaksi:

  1. Verifikasi Offline: Memeriksa sertifikat x5c di header JWS secara langsung terhadap Apple Root CA menggunakan algoritma ECDSA P-256 (ES256).
  2. Verifikasi Online: Mengambil transactionId dari JWS yang dikirimkan client, kemudian memverifikasi statusnya langsung ke endpoint App Store Server API (/inApps/v1/transactions/{transactionId}).

Struktur Data Payload Transaksi

Setelah JWS di-decode dan tanda tangan digital terbukti valid, backend wajib menguji field berikut sebelum memenuhi item pengguna:

  • bundleId: Harus cocok persis dengan Bundle Identifier aplikasi backend. Mencegah receipt dari aplikasi lain dieksploitasi di aplikasi target.
  • environment: Harus bernilai Production untuk server produksi. Jangan pernah mengizinkan receipt dari Sandbox di server live, kecuali akun client ditandai eksplisit sebagai tester internal.
  • productId: Validasi ID item katalog game untuk menentukan kuantitas resource (misal: gold, gems) yang ditambahkan.
  • revocationDate: Jika field ini ada, transaksi telah dibatalkan atau di-refund oleh Apple Customer Support. Item tidak boleh diberikan atau harus ditarik kembali.
  • transactionId dan originalTransactionId: Pengenal unik transaksi. Kunci primer untuk pencegahan replay attack.

Pencegahan Replay Attack dengan Idempotensi Database

Replay attack terjadi ketika satu token tanda terima yang sah dikirimkan berulang kali ke endpoint backend untuk mendapatkan item game berkali-kali. Solusi mutlak untuk masalah ini bukan berada di layer aplikasi, melainkan pada skema database berintegritas tinggi.

Skema Tabel Transaksi IAP

Definisikan tabel pencatatan transaksi dengan unique constraint pada kolom transaction_id:

CREATE TABLE user_iap_transactions (
    id BIGSERIAL PRIMARY KEY,
    user_id BIGINT NOT NULL REFERENCES users(id),
    transaction_id VARCHAR(128) NOT NULL,
    original_transaction_id VARCHAR(128) NOT NULL,
    product_id VARCHAR(64) NOT NULL,
    environment VARCHAR(16) NOT NULL,
    status VARCHAR(20) NOT NULL DEFAULT 'COMPLETED',
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    CONSTRAINT uq_iap_transaction_id UNIQUE (transaction_id)
);
CREATE INDEX idx_iap_user_id ON user_iap_transactions(user_id);

Mitigasi Race Condition Saat Klaim Item

Pengecekan transaksi dengan pola kode SELECT ... WHERE transaction_id = ? yang diikuti oleh INSERT rentan terhadap race condition jika penyerang mengirim puluhan request klaim receipt identik secara paralel dalam milidetik yang sama.

Gunakan transaksi database atomik berbasis operasi INSERT ... ON CONFLICT DO NOTHING atau kunci baris menggunakan isolation level yang tepat. Alternatif lain adalah mengunci resource ID pengguna menggunakan SELECT ... FOR UPDATE.

Contoh Implementasi Backend (Node.js & PostgreSQL)

Implementasi berikut memvalidasi JWS, memeriksa bundle ID serta environment, dan menambahkan resource user secara atomik tanpa bloating library eksternal.

const crypto = require('crypto');
const { Pool } = require('pg');

const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const EXPECTED_BUNDLE_ID = process.env.APP_BUNDLE_ID;
const EXPECTED_ENV = process.env.NODE_ENV === 'production' ? 'Production' : 'Sandbox';

function decodeJwsPayload(jws) {
  const parts = jws.split('.');
  if (parts.length !== 3) {
    throw new Error('Format JWS tidak valid.');
  }
  const payloadBuffer = Buffer.from(parts[1], 'base64url');
  return JSON.parse(payloadBuffer.toString('utf8'));
}

// ponytail: Verifikasi x5c dilewati untuk pemrosesan online via App Store Server API.
// Tambahkan Apple Root CA certificate chain parsing jika verifikasi offline diperlukan.
async function processIapReceipt(userId, signedTransactionJws) {
  const transaction = decodeJwsPayload(signedTransactionJws);

  // 1. Validasi bundleId dan environment
  if (transaction.bundleId !== EXPECTED_BUNDLE_ID) {
    throw new Error('Spoofing terdeteksi: Bundle ID tidak sesuai.');
  }

  if (transaction.environment !== EXPECTED_ENV) {
    throw new Error(`Environment mismatch: Menolak transaksi ${transaction.environment} di mode ${EXPECTED_ENV}.`);
  }

  if (transaction.revocationDate) {
    throw new Error('Transaksi telah di-revoke oleh Apple.');
  }

  const client = await pool.connect();
  try {
    await client.query('BEGIN');

    // 2. Cegah Replay Attack: Insert dengan idempotensi atomik
    const insertTxQuery = `
      INSERT INTO user_iap_transactions (user_id, transaction_id, original_transaction_id, product_id, environment)
      VALUES ($1, $2, $3, $4, $5)
      ON CONFLICT (transaction_id) DO NOTHING
      RETURNING id;
    `;
    const txRes = await client.query(insertTxQuery, [
      userId,
      transaction.transactionId,
      transaction.originalTransactionId,
      transaction.productId,
      transaction.environment
    ]);

    if (txRes.rowCount === 0) {
      // Transaksi sudah pernah diproses sebelumnya. Jangan kreditkan item lagi.
      await client.query('ROLLBACK');
      return { success: true, message: 'Transaksi telah diproses sebelumnya (Idempotent).' };
    }

    // 3. Kreditkan reward secara atomik ke user
    const rewardGems = calculateGems(transaction.productId);
    const updateBalanceQuery = `
      UPDATE users 
      SET gems = gems + $1 
      WHERE id = $2;
    `;
    await client.query(updateBalanceQuery, [rewardGems, userId]);

    await client.query('COMMIT');
    return { success: true, message: 'Item berhasil ditambahkan.' };
  } catch (error) {
    await client.query('ROLLBACK');
    throw error;
  } finally {
    client.release();
  }
}

function calculateGems(productId) {
  const mapping = {
    'com.game.gems_tier_1': 100,
    'com.game.gems_tier_2': 550,
  };
  const gems = mapping[productId];
  if (!gems) throw new Error('Product ID tidak terdaftar.');
  return gems;
}

Manajemen Private Key Apple API

Saat menggunakan App Store Server API untuk sinkronisasi transaksi atau refund notification, backend membutuhkan token JWT bertanda tangan ES256 menggunakan App Store In-App Purchase Private Key (.p8) yang diterbitkan via Apple Developer Portal.

  • Kunci Rahasia (Private Key): Jangan pernah menyimpan file .p8 langsung di dalam git repository. Simpan isi private key dalam secret storage terenkripsi seperti AWS Secrets Manager, HashiCorp Vault, atau injected via environment variable (Base64-encoded).
  • Identitas Kunci: Catat Key ID (10 karakter) dan Issuer ID (format UUID) dari App Store Connect. Sertakan parameter ini pada header token JWT backend saat memanggil API Apple.
  • Rotasi Kunci: Buat prosedur rotasi tanpa downtime. Apple mengizinkan pembuatan beberapa private key secara simultan, sehingga key baru dapat diuji sebelum key lama dihapus.

Ringkasan Tindakan

Keamanan transaksi IAP bergantung pada tiga titik kontrol: verifikasi kriptografi payload di sisi server, isolasi environment Sandbox/Production secara eksplisit, dan eksekusi database idempotent menggunakan unique constraint pada transactionId.