Anatomi Masalah: Client Tampering dan Batasan Logika Frontend

Aplikasi React Native rentan terhadap dekompilasi, reverse engineering, dan injeksi kode runtime menggunakan runtime instrumentation toolkit seperti Frida atau Xposed. Logika validasi yang sepenuhnya bertumpu pada JavaScript bundle (index.android.bundle) mudah di-bypass dengan mengubah conditional statement sederhana atau mengaitkan (hooking) return value modul keamanan.

Untuk memvalidasi bahwa request API backend berasal dari binary aplikasi resmi, berjalan di sistem operasi resmi yang belum di-root, dan tidak dijalankan dalam lingkungan emulator otomatis (seperti pada skenario credential stuffing), backend memerlukan jaminan kriptografis dari platform OS penyedia runtime: Google Play Integrity API untuk Android dan DCAppAttestService (App Attest) untuk iOS.

Arsitektur Nonce-Based Attestation Handshake

Attestation tanpa parameter unik rentan terhadap replay attacks, di mana penyerang merekam token integritas valid lalu mengirimkannya berulang kali bersama payload yang dimanipulasi. Handshake yang aman mewajibkan pembuatan nonce sekali pakai (one-time nonce) yang di-generate oleh backend.

Alur kerja validasi integritas berjalan sebagai berikut:

  1. Nonce Challenge: Client meminta tantangan kriptografis sebelum mengeksekusi aksi sensitif (misal: login, transfer dana, order checkout). Backend membuat nonce acak (minimal 16 byte, di-hash via SHA-256) dan mengaitkannya ke session/TTL sementara di cache (Redis).
  2. Platform Request: JavaScript mengirimkan nonce ke Native Module (Kotlin/Swift). Native Module meneruskan nonce ke Play Integrity SDK / App Attest API.
  3. Attestation Token Generation: Google Play Services atau Secure Enclave iOS menghasilkan payload terenkripsi yang memuat hardware identity, hash binary aplikasi, dan nonce challenge.
  4. Verification Request: React Native mengirimkan token integritas mentah beserta payload bisnis ke backend.
  5. Server-side Decrypt & Verdict: Backend mendekripsi token via Google Play API, memverifikasi kesesuaian nonce, package name, signing certificate SHA-256, serta memeriksa level integritas perangkat sebelum menyetujui transaksi.

Implementasi Native Bridging

1. Android: Kotlin Native Module (Play Integrity)

Tambahkan dependency resmi pada android/app/build.gradle:

dependencies {
    implementation 'com.google.android.play:integrity:1.3.0'
}

Implementasikan Native Module untuk mengeksekusi request token ke Google Play Services:

package com.yourapp.integrity

import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.google.android.play.core.integrity.IntegrityManagerFactory
import com.google.android.play.core.integrity.IntegrityTokenRequest
import com.google.android.play.core.integrity.model.IntegrityErrorCode

class PlayIntegrityModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
    override fun getName() = "PlayIntegrity"

    @ReactMethod
    fun requestIntegrityToken(projectNumber: String, nonce: String, promise: Promise) {
        try {
            val integrityManager = IntegrityManagerFactory.create(reactApplicationContext)
            val request = IntegrityTokenRequest.builder()
                .setCloudProjectNumber(projectNumber.toLong())
                .setNonce(nonce)
                .build()

            integrityManager.requestIntegrityToken(request)
                .addOnSuccessListener { response ->
                    promise.resolve(response.token())
                }
                .addOnFailureListener { exception ->
                    promise.reject("INTEGRITY_FETCH_FAILED", exception.message, exception)
                }
        } catch (e: Exception) {
            promise.reject("UNEXPECTED_ERROR", e.message, e)
        }
    }
}

2. iOS: Swift Native Module (DeviceCheck / App Attest)

Di iOS, Apple menyediakan DCAppAttestService untuk menguji keabsahan aplikasi:

import Foundation
import DeviceCheck
import CryptoKit

@objc(AppAttestModule)
class AppAttestModule: NSObject {
    
    @objc
    func requestAttestation(_ nonce: String, resolver resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) {
        let service = DCAppAttestService.shared
        guard service.isSupported else {
            reject("UNSUPPORTED", "App Attest is not supported on this device", nil)
            return
        }

        service.generateKey { keyId, error in
            if let error = error {
                reject("KEY_GEN_FAILED", error.localizedDescription, error)
                return
            }
            guard let keyId = keyId else {
                reject("KEY_NULL", "KeyId generation returned null", nil)
                return
            }

            guard let clientData = nonce.data(using: .utf8) else {
                reject("INVALID_NONCE", "Failed to convert nonce to data", nil)
                return
            }
            let clientDataHash = Data(SHA256.hash(data: clientData))

            service.attestKey(keyId, clientDataHash: clientDataHash) { attestation, error in
                if let error = error {
                    reject("ATTESTATION_FAILED", error.localizedDescription, error)
                    return
                }
                guard let attestation = attestation else {
                    reject("ATTEST_NULL", "Attestation payload is null", nil)
                    return
                }
                
                let result: [String: String] = [
                    "keyId": keyId,
                    "attestation": attestation.base64EncodedString()
                ]
                resolve(result)
            }
        }
    }
}

3. React Native TypeScript Layer

Abstraksikan modul platform ke antarmuka tunggal:

import { NativeModules, Platform } from 'react-native';

const { PlayIntegrity, AppAttestModule } = NativeModules;

interface AttestationResult {
  token: string;
  keyId?: string;
}

export async function fetchDeviceAttestation(nonce: string, googleProjectNumber: string): Promise<AttestationResult> {
  if (Platform.OS === 'android') {
    const token = await PlayIntegrity.requestIntegrityToken(googleProjectNumber, nonce);
    return { token };
  } else if (Platform.OS === 'ios') {
    const result = await AppAttestModule.requestAttestation(nonce);
    return { token: result.attestation, keyId: result.keyId };
  }
  throw new Error('Platform not supported for hardware attestation');
}

Validasi Verdict di Sisi Backend

Hindari validasi token di sisi client. Seluruh token mentah harus diteruskan ke server. Untuk Android, gunakan Google APIs Client Library untuk mendekripsi verdict token:

import { google } from 'googleapis';

const playIntegrity = google.playintegrity('v1');

async function verifyAndroidToken(integrityToken: string, expectedNonce: string) {
  const auth = new google.auth.GoogleAuth({
    scopes: ['https://www.googleapis.com/auth/playintegrity'],
  });
  const authClient = await auth.getClient();

  const res = await playIntegrity.v1.decodeIntegrityToken({
    packageName: 'com.yourapp',
    requestBody: { integrityToken },
    auth: authClient as any,
  });

  const payload = res.data.tokenPayloadExternal;
  if (!payload) {
    throw new Error('DECODE_EMPTY');
  }

  // 1. Verifikasi Nonce Binding
  if (payload.requestDetails?.nonce !== expectedNonce) {
    throw new Error('NONCE_MISMATCH');
  }

  // 2. Evaluasi App Recognition
  const appVerdict = payload.appIntegrity?.appRecognitionVerdict;
  if (appVerdict !== 'PLAY_RECOGNIZED') {
    throw new Error(`UNRECOGNIZED_BINARY: ${appVerdict}`);
  }

  // 3. Evaluasi Device Recognition
  const deviceVerdict = payload.deviceIntegrity?.deviceRecognitionVerdict || [];
  const meetsBasicIntegrity = deviceVerdict.includes('MEETS_BASIC_INTEGRITY');
  const meetsStrongIntegrity = deviceVerdict.includes('MEETS_STRONG_INTEGRITY');

  if (!meetsBasicIntegrity) {
    throw new Error('COMPROMISED_DEVICE');
  }

  // 4. Evaluasi Account Licensing
  if (payload.accountDetails?.appLicensingVerdict !== 'LICENSED') {
    // Sideloaded APK atau modded distribution
    throw new Error('UNLICENSED_INSTALL');
  }

  return { verified: true, strongHardwareProtected: meetsStrongIntegrity };
}

Handling Edge Cases dan Error Fallback

  • Google Play Services Usang: Jika perangkat pengguna memiliki Google Play Services versi lama, request token akan mengembalikan error API_NOT_AVAILABLE atau SERVICE_DISABLED. Aplikasi harus mengecek ketersediaan via GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context) dan memandu pengguna memperbarui layanan Play Services.
  • Quota Limits: Standard tier Play Integrity memiliki limit harian request decode (biasanya 10.000 per hari jika menggunakan Classic Request API tanpa Play Integrity Cloud console linking). Daftarkan aplikasi ke Google Cloud Console untuk menaikkan tier ke Standard (Unlimited requests) jika volume trafik tinggi.
  • Network Timeout: Handshake attestation melibatkan komunikasi jaringan antara Google Play Services dan server Google. Buat mekanisme retry terikat (maksimal 2x dengan exponential backoff) khusus untuk network error native.

Strategi Caching dan Efisiensi Performa

Memanggil Play Integrity API secara sinkronis pada setiap HTTP request akan mendegradasi performa dan latensi (overhead 300ms–1200ms per pemanggilan hardware native). Gunakan strategi berikut:

Aturan Prinsip Caching: Jangan memvalidasi token integritas pada read-only endpoint biasa. Batasi handshake hanya pada transaksi berdampak tinggi (State-Changing Transactions) seperti authentication, pembayaran, dan eksekusi transfer data kritis.

  • Device Attestation Session Token: Setelah client lolos validasi integrity token pada fase otentikasi awal, backend menerbitkan short-lived verified device session token (misal: disimpan di Redis dengan TTL 15–30 menit, dipetakan ke device_id terenkripsi).
  • Risk-Based Step-Up Challenge: Request API reguler hanya mengecek session token tersebut. Ketika request mendeteksi anomali (perubahan IP drastis, transaksi melewati threshold, deteksi emulator heuristic dasar), backend secara dinamis mengembalikan HTTP 403 Challenge yang mewajibkan React Native melakukan re-attestation ke Play Integrity API.