App Attest ve Play Integrity: sahte istemcileri durdurmanın uçtan uca rehberi

API’niz kiminle konuştuğunu bilmiyor. Tehdit modelinden veri modeline, Swift ve Kotlin istemci kodundan TypeScript ile sunucu doğrulamasına, karar matrisinden kademeli yayın planına kadar App Attest ve Play Integrity’yi üretimde nasıl kurduğumuzu adım adım anlatıyoruz.

Mobil uygulamanızın konuştuğu API, internete açık bir kapıdır. Uygulamanın içine gömdüğünüz API anahtarı, istemci tarafındaki kontroller ve obfuscation, kararlı bir saldırganı yalnızca birkaç saat yavaşlatır. Uygulamanızı indiren herkes binary’yi açabilir, trafiği izleyebilir ve isteklerinizi bir script ile birebir taklit edebilir.

Sahada en sık karşılaştığımız tablolar şunlar:

  • Kampanya ve bonus istismarı: Yeni üye kampanyası, emülatör çiftliklerinden binlerce sahte hesapla tüketiliyor.
  • Scraping: Fiyat, stok ya da içerik API’leri rakipler tarafından otomatik olarak çekiliyor.
  • Değiştirilmiş istemciler: Reklamsız, kilidi açılmış ya da “hileli” sürümler dağıtılıyor ve doğrudan backend’inizle konuşuyor.
  • Credential stuffing: Sızdırılmış şifre listeleri, mobil API’nin daha zayıf hız sınırları üzerinden deneniyor.

Bunların ortak noktası şu soruya cevap verememek: Bu istek gerçekten mağazadan indirilmiş, değiştirilmemiş uygulamamızdan ve gerçek bir cihazdan mı geliyor? iOS’ta App Attest, Android’de Play Integrity API bu soruya platform seviyesinde, kriptografik olarak cevap verir.

Bu yazıda konuyu kavramsal düzeyde bırakmayacağız. Bir projede uçtan uca nasıl kurduğumuzu; tasarım kararlarıyla, çalışan kodla ve sahada düştüğümüz tuzaklarla birlikte anlatacağız.

Neyi çözer, neyi çözmez?

Başlamadan beklentiyi doğru kurmak önemli. Bu mekanizmalar istemci bütünlüğü sinyali üretir:

Soru App Attest Play Integrity
Uygulama bizim imzamızla mı imzalanmış, değiştirilmemiş mi? Evet (anahtar uygulama kimliğine bağlı) Evet (appIntegrity)
Gerçek bir cihaz mı? Evet (Secure Enclave’de üretilmiş anahtar) Evet, seviyeli (deviceIntegrity)
Uygulama mağazadan mı yüklendi, lisanslı mı? Hayır Evet (accountDetails)
Cihazda riskli uygulamalar çalışıyor mu? Hayır İsteğe bağlı (environmentDetails)
Kullanıcı kim, bu işlemi yapmaya yetkili mi? Hayır Hayır

Son satır kritik: bu API’ler kimlik doğrulamanın ve yetkilendirmenin yerine geçmez. Ayrıca jailbreak’li bir iPhone’da çalışan orijinal uygulamanız App Attest’ten geçebilir; çalışma zamanında Frida gibi araçlarla kanca atılmış bir süreç de geçerli bir assertion üretebilir. Bu yüzden sonucu tek başına bir “kapı” olarak değil, risk motorunuzdaki güçlü bir sinyal olarak kullanacağız.

Mimari: tek bir model, iki platform

İki platformu aynı sunucu mimarisi altında topluyoruz. Böylece karar politikası, loglama ve izleme tek yerde kalıyor.

  1. Challenge servisi: Sunucu tek kullanımlık, kısa ömürlü (örneğin 5 dakika), tahmin edilemez bir değer üretir ve Redis gibi bir depoda tutar.
  2. İstemci verisi (client data): Uygulama, hassas bir istekten önce challenge’ı alır ve isteğin özünü içeren küçük bir JSON üretir: challenge, HTTP metodu, yol ve gövdenin SHA-256 özeti.
  3. Platform kanıtı: iOS’ta bu JSON’un özeti için bir assertion, Android’de aynı özet requestHash olarak kullanılarak bir integrity token alınır.
  4. Sunucu doğrulaması: Sunucu, istemci verisini gerçek istekle karşılaştırır, challenge’ı tüketir, platform kanıtını doğrular ve bir güven seviyesi üretir.
  5. Karar: Uç noktanın risk seviyesine göre izin verilir, ek doğrulama istenir ya da reddedilir.

İstemci verisini neden ayrıca gönderiyoruz? Çünkü kanıtın bu isteğe bağlı olmasını istiyoruz. Yalnızca challenge’ı imzalatırsanız, geçerli bir kanıt yakalayan biri onu farklı bir gövdeyle başka bir istekte kullanabilir. Gövde özetini de içeren bir JSON imzalatıldığında bu mümkün olmaz. JSON’u istemci olduğu gibi (base64 olarak) gönderir, sunucu aldığı baytların özetini hesaplar. Böylece iki tarafta JSON’u aynı biçimde üretme (canonicalization) derdi de ortadan kalkar.

Uç noktalar

POST /v1/integrity/challenge          → { "id": "...", "value": "base64url", "expiresAt": "..." }
POST /v1/integrity/ios/register       ← { keyId, attestation, challengeId }   (yalnızca iOS, anahtar başına bir kez)
POST /v1/orders  (korunan herhangi bir uç nokta)
     X-Integrity-Platform: ios | android
     X-Integrity-Client-Data: base64(JSON)
     X-App-Key-Id / X-App-Assertion   (iOS)
     X-Play-Integrity-Token           (Android)

Veri modeli

CREATE TABLE app_attest_keys (
  key_id         TEXT PRIMARY KEY,           -- base64 keyId (açık anahtarın SHA-256’sı)
  install_id     TEXT NOT NULL,              -- uygulama kurulumu / cihaz kaydı
  user_id        TEXT,                       -- giriş yapılmışsa
  public_key_pem TEXT NOT NULL,
  sign_count     BIGINT NOT NULL DEFAULT 0,  -- son kabul edilen assertion sayacı
  environment    TEXT NOT NULL,              -- 'production' | 'development'
  receipt        BYTEA,                      -- risk metriği için Apple makbuzu
  created_at     TIMESTAMPTZ NOT NULL DEFAULT now(),
  last_used_at   TIMESTAMPTZ
);

CREATE TABLE integrity_events (            -- izleme ve karar analizi için
  id          BIGSERIAL PRIMARY KEY,
  platform    TEXT NOT NULL,
  endpoint    TEXT NOT NULL,
  trust       TEXT NOT NULL,                -- trusted | degraded | untrusted | unavailable
  reason      TEXT,
  app_version TEXT,
  os_version  TEXT,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

iOS: App Attest

Hazırlık

  • Xcode’da hedefe App Attest yeteneğini ekleyin. Bu, com.apple.developer.devicecheck.appattest-environment entitlement’ını oluşturur. Değeri development ya da production olabilir; geliştirme build’lerinde varsayılan development (sandbox) ortamıdır, TestFlight, App Store ya da Enterprise ile dağıtılan build’ler ise entitlement’ta ne yazarsa yazsın production ortamını kullanır. Sunucu tarafında bu ortamlar ayrı ele alınmalıdır.
  • App Attest simülatörde çalışmaz. DCAppAttestService.shared.isSupported simülatörde ve bazı ortamlarda false döner.
  • Apple, büyük kullanıcı tabanlarında servisi kademeli devreye almanızı öneriyor. Yeni bir sürümle birlikte milyonlarca cihazın aynı gün attestation yapması, Apple tarafında hız sınırlamasına takılmanıza yol açabilir.

İstemci: anahtar yaşam döngüsünü yöneten bir actor

Anahtar üretimi, attestation ve assertion üretimini tek bir yerde topluyoruz. keyId Keychain’de saklanır; anahtarın kendisi Secure Enclave’de kalır ve hiçbir zaman dışarı çıkmaz.

import CryptoKit
import DeviceCheck
import Foundation

struct ClientData: Codable {
    let challenge: String
    let method: String
    let path: String
    let bodySHA256: String
}

enum IntegrityError: Error { case unsupported, attestationRejected }

actor AppAttestManager {
    private let service = DCAppAttestService.shared
    private let api: IntegrityAPI          // challenge ve register çağrılarını yapan katman
    private let keychain: KeychainStore
    private var keyId: String?

    init(api: IntegrityAPI, keychain: KeychainStore) {
        self.api = api
        self.keychain = keychain
    }

    /// Attest edilmiş bir anahtar döner; yoksa üretir, attest eder ve sunucuya kaydeder.
    func attestedKeyId() async throws -> String {
        if let keyId { return keyId }
        if let saved = keychain.string(for: "appattest.keyId") {
            keyId = saved
            return saved
        }
        guard service.isSupported else { throw IntegrityError.unsupported }

        let newKey = try await service.generateKey()
        let challenge = try await api.fetchChallenge()
        let clientDataHash = Data(SHA256.hash(data: challenge.valueData))
        let attestation = try await attestWithRetry(newKey, clientDataHash: clientDataHash)
        try await api.registerKey(keyId: newKey, attestation: attestation, challengeId: challenge.id)

        keychain.set(newKey, for: "appattest.keyId")
        keyId = newKey
        return newKey
    }

    /// İsteğe App Attest başlıklarını ekler.
    func sign(_ request: URLRequest) async throws -> URLRequest {
        let keyId = try await attestedKeyId()
        let challenge = try await api.fetchChallenge()
        let clientData = ClientData(
            challenge: challenge.value,
            method: request.httpMethod ?? "GET",
            path: request.url?.path ?? "/",
            bodySHA256: SHA256.hash(data: request.httpBody ?? Data()).hexString
        )
        let clientDataJSON = try JSONEncoder().encode(clientData)
        let hash = Data(SHA256.hash(data: clientDataJSON))

        let assertion: Data
        do {
            assertion = try await service.generateAssertion(keyId, clientDataHash: hash)
        } catch let error as DCError where error.code == .invalidKey {
            // Anahtar geçersizleşmiş (ör. yedekten geri yükleme): sıfırla ve bir kez yeniden dene
            keychain.remove("appattest.keyId")
            self.keyId = nil
            return try await sign(request)
        }

        var signed = request
        signed.setValue("ios", forHTTPHeaderField: "X-Integrity-Platform")
        signed.setValue(keyId, forHTTPHeaderField: "X-App-Key-Id")
        signed.setValue(assertion.base64EncodedString(), forHTTPHeaderField: "X-App-Assertion")
        signed.setValue(clientDataJSON.base64EncodedString(), forHTTPHeaderField: "X-Integrity-Client-Data")
        return signed
    }

    private func attestWithRetry(_ keyId: String, clientDataHash: Data, attempts: Int = 3) async throws -> Data {
        var delay: UInt64 = 500_000_000
        for attempt in 1...attempts {
            do {
                return try await service.attestKey(keyId, clientDataHash: clientDataHash)
            } catch let error as DCError where error.code == .serverUnavailable && attempt < attempts {
                // Geçici hata: aynı anahtarla tekrar dene, yeni anahtar üretme
                try await Task.sleep(nanoseconds: delay)
                delay *= 2
            }
        }
        throw IntegrityError.attestationRejected
    }
}

extension SHA256.Digest {
    var hexString: String { map { String(format: "%02x", $0) }.joined() }
}

Birkaç önemli ayrıntı:

  • serverUnavailable geçicidir. Apple bu durumda aynı anahtarla daha sonra tekrar denemenizi söylüyor. Her hatada yeni anahtar üretmek, cihaz başına anahtar sayısını şişirir ve risk metriğinizi bozar.
  • invalidKey genellikle uygulamanın yedekten geri yüklenmesi ya da başka bir cihaza taşınması sonrası görülür. App Attest anahtarları cihaza bağlıdır ve taşınmaz. Sunucunuz, aynı kullanıcı için yeni bir anahtar kaydını normal bir olay olarak kabul etmelidir.
  • Challenge’ı tek seferde iki iş için kullanmayın. Kayıt (attestation) ve her korunan istek (assertion) kendi challenge’ını alır.

Sunucu: attestation doğrulaması

Apple’ın dokümantasyonundaki doğrulama adımlarını Node.js (TypeScript) ile aşağıdaki gibi uyguluyoruz. CBOR çözümlemesi için cbor-x, sertifika uzantısını okumak için @peculiar/x509 kullanıyoruz. Zincir doğrulaması ve imza işlemleri Node’un kendi crypto modülüyle yapılıyor.

import { createHash, timingSafeEqual, X509Certificate } from "node:crypto";
import { decode } from "cbor-x";
import * as x509 from "@peculiar/x509";

// Apple PKI sayfasından indirilen "Apple App Attestation Root CA" (PEM)
import { APPLE_APP_ATTEST_ROOT_PEM } from "./apple-root";

const APP_ID = `${process.env.APPLE_TEAM_ID}.${process.env.IOS_BUNDLE_ID}`;
const NONCE_OID = "1.2.840.113635.100.8.2";
const AAGUID_PROD = Buffer.concat([Buffer.from("appattest"), Buffer.alloc(7)]);
const AAGUID_DEV = Buffer.from("appattestdevelop");

const sha256 = (...parts: Buffer[]) => createHash("sha256").update(Buffer.concat(parts)).digest();
const same = (a: Buffer, b: Buffer) => a.length === b.length && timingSafeEqual(a, b);

interface AuthData {
  rpIdHash: Buffer;
  signCount: number;
  aaguid?: Buffer;
  credentialId?: Buffer;
}

// authenticatorData: rpIdHash(32) | flags(1) | signCount(4) | [aaguid(16) | credIdLen(2) | credId | COSE key]
function parseAuthData(buf: Buffer): AuthData {
  const rpIdHash = buf.subarray(0, 32);
  const signCount = buf.readUInt32BE(33);
  if (buf.length <= 37) return { rpIdHash, signCount };
  const aaguid = buf.subarray(37, 53);
  const idLength = buf.readUInt16BE(53);
  const credentialId = buf.subarray(55, 55 + idLength);
  return { rpIdHash, signCount, aaguid, credentialId };
}

// Sertifikadaki EC açık anahtarını sıkıştırılmamış nokta (0x04 | X | Y) olarak döner
function uncompressedPoint(cert: X509Certificate): Buffer {
  const jwk = cert.publicKey.export({ format: "jwk" });
  return Buffer.concat([Buffer.from([0x04]), Buffer.from(jwk.x!, "base64url"), Buffer.from(jwk.y!, "base64url")]);
}

export function verifyAttestation(keyIdB64: string, attestation: Buffer, challenge: Buffer) {
  const att = decode(attestation);
  if (att.fmt !== "apple-appattest") throw new Error("attestation: unexpected fmt");

  // 1. Sertifika zinciri: credCert ← ara sertifika ← Apple App Attestation Root CA
  const [credDer, intermediateDer] = att.attStmt.x5c as Buffer[];
  const credCert = new X509Certificate(credDer);
  const intermediate = new X509Certificate(intermediateDer);
  const root = new X509Certificate(APPLE_APP_ATTEST_ROOT_PEM);
  const now = Date.now();
  for (const c of [credCert, intermediate]) {
    if (now < Date.parse(c.validFrom) || now > Date.parse(c.validTo)) throw new Error("attestation: certificate expired");
  }
  if (!credCert.verify(intermediate.publicKey) || !intermediate.verify(root.publicKey)) {
    throw new Error("attestation: certificate chain");
  }

  // 2-4. nonce = SHA256(authData || SHA256(challenge)) sertifikadaki uzantıyla eşleşmeli
  const authDataRaw: Buffer = att.authData;
  const nonce = sha256(authDataRaw, sha256(challenge));
  const ext = new x509.X509Certificate(credDer).getExtension(NONCE_OID);
  if (!ext) throw new Error("attestation: nonce extension missing");
  // Uzantı DER olarak SEQUENCE { [1] { OCTET STRING (32 bayt) } } yapısındadır; son 32 bayt nonce’tur
  const certNonce = Buffer.from(ext.value).subarray(-32);
  if (!same(certNonce, nonce)) throw new Error("attestation: nonce mismatch");

  // 5. keyId, açık anahtarın SHA-256’sı olmalı
  const keyId = Buffer.from(keyIdB64, "base64");
  if (!same(sha256(uncompressedPoint(credCert)), keyId)) throw new Error("attestation: key id mismatch");

  // 6-9. authenticatorData kontrolleri
  const auth = parseAuthData(authDataRaw);
  if (!same(auth.rpIdHash, sha256(Buffer.from(APP_ID)))) throw new Error("attestation: app id mismatch");
  if (auth.signCount !== 0) throw new Error("attestation: counter must be 0");
  const environment = auth.aaguid && same(auth.aaguid, AAGUID_PROD) ? "production"
    : auth.aaguid && same(auth.aaguid, AAGUID_DEV) ? "development" : null;
  if (!environment) throw new Error("attestation: unknown aaguid");
  if (!auth.credentialId || !same(auth.credentialId, keyId)) throw new Error("attestation: credential id mismatch");

  return {
    publicKeyPem: credCert.publicKey.export({ type: "spki", format: "pem" }).toString(),
    receipt: att.attStmt.receipt as Buffer,
    environment,
  };
}

Üretimde yalnızca production ortamından gelen anahtarları kabul edin; development anahtarları ayrı bir test ortamına yönlendirilmelidir. Bu örnek Apple’ın ilk dokuz adımını kapsıyor; güncel dokümantasyon bunlara, authenticator data’nın sonundaki extensions CBOR sözlüğünde gelen apple_validation_category_01 (build’in App Store, TestFlight ya da geliştirme imzasıyla mı dağıtıldığı) ve apple_bundle_version_01 değerlerinin kontrolünü de ekliyor. Bu alanların hangi işletim sistemi sürümlerinde geldiğini kendi cihaz örneklerinizle doğrulayıp politikanıza ekleyin. Kayıt uç noktası; challenge’ı tükettikten sonra (Redis’te GETDEL gibi atomik bir işlemle) bu fonksiyonu çağırır ve sonucu app_attest_keys tablosuna yazar.

Sunucu: her istekte assertion doğrulaması

import { createPublicKey, verify } from "node:crypto";

export function verifyAssertion(stored: { publicKeyPem: string; signCount: number }, assertion: Buffer, clientData: Buffer) {
  const { signature, authenticatorData } = decode(assertion) as { signature: Buffer; authenticatorData: Buffer };

  // nonce = SHA256(authenticatorData || SHA256(clientData)); imza ECDSA P-256 / SHA-256
  const nonce = sha256(authenticatorData, sha256(clientData));
  if (!verify("sha256", nonce, createPublicKey(stored.publicKeyPem), signature)) {
    throw new Error("assertion: bad signature");
  }

  const auth = parseAuthData(authenticatorData);
  if (!same(auth.rpIdHash, sha256(Buffer.from(APP_ID)))) throw new Error("assertion: app id mismatch");
  if (auth.signCount <= stored.signCount) throw new Error("assertion: replayed counter");

  return auth.signCount;   // kabul edildiyse yeni değeri kaydedin
}

Sayacı güncellemek atomik olmalı. Aynı anahtarla eşzamanlı iki istek geldiğinde ikisinin de eski sayaca göre kabul edilmemesi için koşullu güncelleme kullanın:

UPDATE app_attest_keys
   SET sign_count = $2, last_used_at = now()
 WHERE key_id = $1 AND sign_count < $2;
-- etkilenen satır sayısı 0 ise isteği tekrar (replay) olarak reddedin

Risk metriği: aynı cihazda kaç anahtar var?

Attestation ile birlikte gelen makbuz (receipt), Apple’ın sunucusuna gönderilerek bir risk metriği alınabilir: yaklaşık son 30 gün içinde o cihazda uygulamanız için kaç anahtar attest edildiği. Normal bir kullanıcıda bu sayı düşüktür; aynı fiziksel cihazdan çok sayıda anahtar, sıfırlanıp yeniden kurulan otomasyon cihazlarına işaret edebilir. Bu metriği kampanya ve kayıt akışlarında ek bir sinyal olarak kullanıyoruz.

Android: Play Integrity API

Hazırlık

  • Play Console’da uygulamanız için Play Integrity API’yi etkinleştirin ve bir Google Cloud projesine bağlayın. İstemci tarafında bu projenin cloud project number değeri kullanılır.
  • Sunucu tarafında, token’ları çözmek için playintegrity kapsamına sahip bir servis hesabı gerekir.
  • API’nin günlük bir varsayılan kotası vardır. Trafiğiniz bunu aşacaksa artış talebini önceden yapın: Cloud projenizin Play Console’da uygulamaya bağlı olması gerekir, talep Google’ın kota artış formuyla iletilir ve işlenmesi bir haftayı bulabilir. Kota aşımında istemci TOO_MANY_REQUESTS hatası alır.

Standard mı, classic mi?

Standard request Classic request
Kullanım Sık yapılan, kullanıcı etkileşimi sırasındaki istekler Seyrek, yüksek değerli işlemler
Gecikme Düşük (sağlayıcı önceden hazırlanır) Daha yüksek
İsteğe bağlama requestHash nonce
Token çözme Yalnızca Google sunucuları Google sunucuları ya da kendi anahtarlarınızla yerel

Çoğu uygulamada standard request yeterlidir. Biz classic request’i yalnızca, örneğin hesap kurtarma gibi nadir ama kritik işlemlerde ek bir kontrol olarak kullanıyoruz.

İstemci: hazırlanmış sağlayıcıyı yöneten bir sınıf

kotlinx-coroutines-play-services ile Task API’sini coroutine’e çeviriyoruz. Sağlayıcı geçersizleşirse (INTEGRITY_TOKEN_PROVIDER_INVALID) yeniden hazırlıyoruz.

import android.content.Context
import com.google.android.play.core.integrity.IntegrityManagerFactory
import com.google.android.play.core.integrity.StandardIntegrityException
import com.google.android.play.core.integrity.StandardIntegrityManager.PrepareIntegrityTokenRequest
import com.google.android.play.core.integrity.StandardIntegrityManager.StandardIntegrityToken
import com.google.android.play.core.integrity.StandardIntegrityManager.StandardIntegrityTokenProvider
import com.google.android.play.core.integrity.StandardIntegrityManager.StandardIntegrityTokenRequest
import com.google.android.play.core.integrity.model.StandardIntegrityErrorCode
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.tasks.await

class PlayIntegrityGate(context: Context, private val cloudProjectNumber: Long) {
    private val manager = IntegrityManagerFactory.createStandard(context.applicationContext)
    private val mutex = Mutex()
    private var provider: StandardIntegrityTokenProvider? = null

    /** Uygulama açılışında ya da kritik akıştan önce çağırın; tekrar çağrılması güvenlidir. */
    suspend fun prepare(): StandardIntegrityTokenProvider = mutex.withLock {
        provider ?: manager.prepareIntegrityToken(
            PrepareIntegrityTokenRequest.builder()
                .setCloudProjectNumber(cloudProjectNumber)
                .build()
        ).await().also { provider = it }
    }

    /** requestHash: istemci verisi JSON’unun SHA-256 hex özeti */
    suspend fun token(requestHash: String): String {
        return try {
            request(prepare(), requestHash)
        } catch (e: StandardIntegrityException) {
            if (e.errorCode == StandardIntegrityErrorCode.INTEGRITY_TOKEN_PROVIDER_INVALID) {
                mutex.withLock { provider = null }
                request(prepare(), requestHash)   // bir kez yeniden hazırla ve dene
            } else {
                throw e
            }
        }
    }

    private suspend fun request(p: StandardIntegrityTokenProvider, requestHash: String): String {
        val response: StandardIntegrityToken = p.request(
            StandardIntegrityTokenRequest.builder()
                .setRequestHash(requestHash)
                .build()
        ).await()
        return response.token()
    }
}

OkHttp kullanıyorsanız korunan uç noktalara giden istekleri bir interceptor içinde imzalamak en temiz yoldur. Interceptor senkron çalıştığı için bu örnekte runBlocking kullanıyoruz; OkHttp zaten istekleri arka plan iş parçacıklarında yürütür. Challenge alma çağrısının (api.fetchChallengeBlocking) aynı interceptor’dan geçmemesine dikkat edin.

class IntegrityInterceptor(
    private val gate: PlayIntegrityGate,
    private val api: IntegrityApi,
    private val protectedPaths: Set<String>,
) : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val original = chain.request()
        if (original.url.encodedPath !in protectedPaths) return chain.proceed(original)

        val body = Buffer().also { original.body?.writeTo(it) }.readByteArray()
        val challenge = api.fetchChallengeBlocking()
        val clientData = JSONObject()
            .put("challenge", challenge.value)
            .put("method", original.method)
            .put("path", original.url.encodedPath)
            .put("bodySHA256", body.sha256Hex())
            .toString()
            .toByteArray()

        val token = runCatching { runBlocking { gate.token(clientData.sha256Hex()) } }.getOrNull()

        val signed = original.newBuilder()
            .header("X-Integrity-Platform", "android")
            .header("X-Integrity-Client-Data", Base64.encodeToString(clientData, Base64.NO_WRAP))
            .apply { if (token != null) header("X-Play-Integrity-Token", token) }
            .build()
        return chain.proceed(signed)
    }
}

Token alınamazsa isteği yine de gönderiyoruz; token eksikliği sunucuda unavailable olarak değerlendirilip karar politikasına bırakılıyor. Play Store’u olmayan ya da güncel olmayan cihazlarda (PLAY_STORE_VERSION_OUTDATED, PLAY_SERVICES_NOT_FOUND) istemcinin çökmesi ya da sonsuz döngüye girmesi en sık gördüğümüz hatalardan.

Sunucu: token’ı çözme ve verdict’i yorumlama

import { google } from "googleapis";
import { createHash } from "node:crypto";

const auth = new google.auth.GoogleAuth({ scopes: ["https://www.googleapis.com/auth/playintegrity"] });
const playintegrity = google.playintegrity({ version: "v1", auth });

const PACKAGE = process.env.ANDROID_PACKAGE!;
const RELEASE_CERT_SHA256 = process.env.ANDROID_CERT_SHA256!;  // Play imza anahtarının SHA-256 özeti (base64url)
const MAX_AGE_MS = 2 * 60 * 1000;

type Trust = "trusted" | "degraded" | "untrusted";

export async function evaluatePlayIntegrity(token: string, clientData: Buffer): Promise<{ trust: Trust; reason: string }> {
  const res = await playintegrity.v1.decodeIntegrityToken({
    packageName: PACKAGE,
    requestBody: { integrityToken: token },
  });
  const p = res.data.tokenPayloadExternal!;

  // İstek bağlama ve tazelik
  const expectedHash = createHash("sha256").update(clientData).digest("hex");
  if (p.requestDetails?.requestPackageName !== PACKAGE) return { trust: "untrusted", reason: "package" };
  if (p.requestDetails?.requestHash !== expectedHash) return { trust: "untrusted", reason: "request-hash" };
  if (Date.now() - Number(p.requestDetails?.timestampMillis) > MAX_AGE_MS) return { trust: "untrusted", reason: "stale" };

  // Uygulama bütünlüğü
  const app = p.appIntegrity;
  if (app?.appRecognitionVerdict !== "PLAY_RECOGNIZED") return { trust: "untrusted", reason: `app:${app?.appRecognitionVerdict}` };
  if (!app.certificateSha256Digest?.includes(RELEASE_CERT_SHA256)) return { trust: "untrusted", reason: "signing-cert" };

  // Cihaz bütünlüğü
  const device = p.deviceIntegrity?.deviceRecognitionVerdict ?? [];
  if (!device.includes("MEETS_DEVICE_INTEGRITY")) {
    return device.includes("MEETS_BASIC_INTEGRITY")
      ? { trust: "degraded", reason: "basic-integrity-only" }
      : { trust: "untrusted", reason: "device" };
  }

  // İsteğe bağlı ortam sinyalleri (Play Console’dan açılmışsa)
  const risk = p.environmentDetails?.playProtectVerdict;
  if (risk === "HIGH_RISK") return { trust: "degraded", reason: "play-protect-high-risk" };

  return { trust: "trusted", reason: "ok" };
}

Verdict alanlarını yorumlarken dikkat edilecekler:

  • appRecognitionVerdict: PLAY_RECOGNIZED dışındaki değerler (UNRECOGNIZED_VERSION, UNEVALUATED) her zaman saldırı anlamına gelmez. Örneğin Play’e henüz yüklenmemiş bir dahili test build’i UNRECOGNIZED_VERSION döner. Test ortamınızı bu yüzden ayrı tutun.
  • deviceRecognitionVerdict bir dizidir. Varsayılan etiket MEETS_DEVICE_INTEGRITY’dir; MEETS_BASIC_INTEGRITY ve MEETS_STRONG_INTEGRITY etiketleri yalnızca Play Console’dan açarsanız gelir (yukarıdaki koddaki degraded dalı bu yüzden opt-in gerektirir). MEETS_VIRTUAL_INTEGRITY ise yalnızca Google Play Games for PC’ye dağıtılan uygulamalarda, Google Play servisli emülatör için döner. Çoğu uygulama için eşik MEETS_DEVICE_INTEGRITY olmalı; STRONG seviyesini yalnızca ödeme gibi en kritik akışlarda düşünün, çünkü Android 13 ve üzerinde son bir yıl içinde güvenlik güncellemesi almış olmayı da şart koşar ve güncelleme almayan cihazları dışarıda bırakır.
  • accountDetails.appLicensingVerdict: Ücretli uygulamalar ya da içerik için LICENSED kontrolü yapın.
  • environmentDetails (appAccessRiskVerdict, playProtectVerdict) ve deviceIntegrity.recentDeviceActivity gibi ek sinyaller Play Console’dan ayrıca açılır. Ekran kaydeden ya da kontrol eden uygulamalar (KNOWN_CAPTURING, UNKNOWN_CONTROLLING gibi) bankacılık gibi alanlarda anlamlıdır; her uygulamada reddetme sebebi olmamalıdır.

Karar politikası: hemen engellemeyin

İlk sürümde “doğrulama başarısızsa reddet” kuralını tüm kullanıcılara uygulamak, en sık gördüğümüz ve en pahalı hatadır. Eski cihazlar, özel ROM’lar, Play Store’u olmayan cihazlar ve geçici servis hataları gerçek kullanıcıları da etkiler.

Her uç noktaya bir risk sınıfı atıyor ve platformdan gelen güven seviyesini bu sınıfla birleştiriyoruz:

Güven seviyesi Düşük risk (katalog, içerik) Orta risk (sepet, profil) Yüksek risk (ödeme, kampanya, hesap değişikliği)
trusted İzin ver İzin ver İzin ver
degraded İzin ver İzin ver, işaretle Ek doğrulama (OTP, 3-D Secure)
unavailable (token yok, servis hatası) İzin ver İzin ver, hız sınırını sıkılaştır Ek doğrulama
untrusted İzin ver, logla Ek doğrulama Reddet

“Ek doğrulama”nın kullanıcıyı engellemediğine, yalnızca maliyeti artırdığına dikkat edin. Bot için OTP pahalıdır; gerçek kullanıcı için birkaç saniyelik bir adımdır.

Kademeli yayın planı

  1. Hafta 0, enstrümantasyon: İstemci kodunu yayınlayın ama sunucu yalnızca doğrulasın ve integrity_events tablosuna yazsın. Hiçbir isteği reddetmeyin.
  2. Hafta 1–3, gözlem: Güven seviyesi dağılımını; uygulama sürümü, işletim sistemi sürümü ve cihaz modeli kırılımlarında izleyin. Beklenmedik bir segmentte untrusted oranı yüksekse (örneğin belirli bir üreticinin cihazları) önce onu anlayın.
  3. Hafta 4, yüksek riskli uç noktalar: Karar matrisini yalnızca yüksek risk sınıfı için devreye alın. Bunu bir özellik bayrağının (feature flag) arkasında yapın ki sorun olursa dakikalar içinde geri alabilesiniz.
  4. Sonrası, genişletme: Metrikler sağlıklıysa orta risk sınıfını ekleyin. App Attest’i iOS kullanıcılarına kademeli açmaya devam edin.

İzlediğimiz metrikler

  • Platform bazında attestation ve token alma başarı oranı
  • Güven seviyesi dağılımı (trusted / degraded / untrusted / unavailable)
  • Hata kodu dağılımı: DCError kodları, Play Integrity hata kodları
  • Doğrulama gecikmesi (p95) ve decodeIntegrityToken hata oranı
  • Günlük Play Integrity kota kullanımı
  • Ek doğrulamaya yönlendirilen ve reddedilen istek oranı, bu oranın destek taleplerine etkisi

Test etmek

  • iOS: Gerçek cihaz gerekir. Geliştirme build’lerinde development ortamı kullanılır ve aaguid appattestdevelop olur (Apple’ın doğrulama adımları bu değeri verir, “Preparing to use the App Attest service” sayfası ise appattestsandbox der; kendi cihazınızdan gelen değeri loglayıp teyit edin); sunucunuzun test ortamı bu anahtarları kabul etmeli, üretim ortamı reddetmelidir. Doğrulama kodunuz için gerçek cihazdan alınmış birkaç attestation ve assertion örneğini test fixture’ı olarak saklayın; zincir, nonce, sayaç ve rpIdHash kontrollerinin her biri için bozulmuş birer örnekle negatif test yazın.
  • Android: Play Console’da Play Integrity API testleri tanımlayarak seçtiğiniz test kullanıcıları için farklı verdict’ler üretebilirsiniz. Dahili test kanalından (internal testing) yüklenen build’ler PLAY_RECOGNIZED döner; Android Studio’dan doğrudan yüklenen build’ler dönmez, bu beklenen bir davranıştır.
  • Sunucu: Challenge’ın ikinci kez kullanılamadığını, süresi geçmiş challenge’ın reddedildiğini, gövdesi değiştirilmiş bir isteğin istemci verisi kontrolünden geçmediğini ve aynı sayaçla gelen ikinci assertion’ın reddedildiğini otomatik testlerle doğrulayın.

Firebase App Check mi, doğrudan entegrasyon mu?

Firebase App Check, App Attest ve Play Integrity’yi sağlayıcı olarak kullanır ve size kendi kısa ömürlü token’ını verir. Firebase servisleri kullanıyorsanız ya da hızlı başlamak istiyorsanız iyi bir seçenektir. Kendi backend’iniz için Admin SDK ile App Check token’ını doğrulayabilir, tekrar kullanıma karşı “limited-use” token’ları tüketebilirsiniz.

Doğrudan entegrasyonu ise şu durumlarda tercih ediyoruz:

  • Kanıtın istek içeriğine bağlanması gerekiyorsa (bu yazıdaki istemci verisi yaklaşımı).
  • Verdict’lerin ayrıntısına (cihaz seviyesi, ortam sinyalleri, risk metriği) göre farklı kararlar vermek istiyorsanız.
  • Doğrulama ve karar mantığının tamamen kendi altyapınızda kalması gerekiyorsa.

Sık yapılan hatalar

  • Kanıtı istemcide doğrulamak ya da istemcinin “güvenilirim” beyanına güvenmek.
  • Challenge’ı tekrar kullanmak, süresiz bırakmak ya da kanıtı isteğin içeriğine bağlamamak.
  • iOS’ta assertion sayacını kontrol etmemek ya da sayacı atomik olmayan bir şekilde güncellemek.
  • Üretimde development ortamından gelen App Attest anahtarlarını kabul etmek.
  • Android’de imza sertifikası özetini kontrol etmemek; yalnızca paket adına güvenmek.
  • serverUnavailable ya da INTEGRITY_TOKEN_PROVIDER_INVALID gibi geçici hatalarda sonsuz döngüye girmek ya da her seferinde yeni anahtar üretmek.
  • Desteklenmeyen cihazlar için geri dönüş politikası tanımlamamak ve ilk günden reddetmeye başlamak.
  • Bu mekanizmaları kimlik doğrulamanın ya da yetkilendirmenin yerine koymak.

Sık sorulan sorular

Her isteği mi korumalıyım? Hayır. Önce iş açısından en değerli uç noktaları seçin: kayıt, giriş, kampanya, ödeme, hesap değişiklikleri ve scraping hedefi olan listeleme uç noktaları. Her isteği korumak hem gecikme hem kota maliyeti getirir.

Challenge için ayrı bir istek gecikmeyi artırmaz mı? Artırır; bu yüzden yalnızca korunan uç noktalarda yapıyoruz. Bir sonraki istek için challenge’ı önceki yanıtın içinde göndermek de bu ek gidiş-dönüşü ortadan kaldırmanın yaygın bir yoludur.

Jailbreak ya da root tespiti hâlâ gerekli mi? Bu API’ler cihaz bütünlüğü hakkında güçlü bir sinyal verir ama çalışma zamanındaki kancalamayı her zaman yakalamaz. Yüksek riskli uygulamalarda MASVS-RESILIENCE kapsamındaki kontrolleri ek sinyal olarak kullanmaya devam ediyoruz.

Web ve masaüstü istemcilerim de aynı API’yi kullanıyor. Ne olacak? Bu mekanizmalar yalnızca mobil istemciler içindir. Aynı uç noktayı kullanan diğer istemciler için ayrı bir güven yolu (ör. web için bot yönetimi) tanımlayın ve karar politikasını istemci tipine göre ayırın.

Kontrol listesi

  • Korunacak uç noktalar ve risk sınıfları belirlendi.
  • Challenge sunucuda üretiliyor; tek kullanımlık, kısa ömürlü ve atomik olarak tüketiliyor.
  • Kanıt, istemci verisi aracılığıyla isteğin içeriğine (metot, yol, gövde özeti) bağlı.
  • iOS: attestation’da zincir, nonce, keyId, rpIdHash, sayaç, aaguid ve credentialId kontrol ediliyor.
  • iOS: assertion sayacı atomik olarak güncelleniyor; üretimde yalnızca production anahtarları kabul ediliyor.
  • Android: requestHash, paket adı, tazelik, PLAY_RECOGNIZED, imza sertifikası ve cihaz seviyesi kontrol ediliyor.
  • Geçici hatalar için yeniden deneme, desteklenmeyen cihazlar için geri dönüş politikası var.
  • Gözlem modu ile başlandı; yaptırım özellik bayrağı arkasında ve kademeli.
  • Başarı oranları, güven dağılımı, hata kodları, gecikme ve kota izleniyor.
  • Sonuçlar hız sınırlama, risk metriği ve davranış sinyalleriyle birlikte değerlendiriliyor.

Bu konuda desteğe mi ihtiyacınız var?

Uygulamanızda bu kontrollerin doğru kurulup kurulmadığını birlikte inceleyelim. Güvenlik denetimi ya da danışmanlıkla buradayız.

Yorumlar ve sorular

Katılmadığınız bir nokta ya da projenizde takıldığınız bir yer mi var? Yazın, konuşalım.