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.
- 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.
- İ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.
- Platform kanıtı: iOS’ta bu JSON’un özeti için bir assertion, Android’de aynı özet
requestHasholarak kullanılarak bir integrity token alınır. - 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.
- 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-environmententitlement’ını oluşturur. Değeridevelopmentya daproductionolabilir; geliştirme build’lerinde varsayılandevelopment(sandbox) ortamıdır, TestFlight, App Store ya da Enterprise ile dağıtılan build’ler ise entitlement’ta ne yazarsa yazsınproductionortamını kullanır. Sunucu tarafında bu ortamlar ayrı ele alınmalıdır. - App Attest simülatörde çalışmaz.
DCAppAttestService.shared.isSupportedsimülatörde ve bazı ortamlardafalsedö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ı:
serverUnavailablegeç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.invalidKeygenellikle 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
playintegritykapsamı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_REQUESTShatası 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_RECOGNIZEDdışı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’iUNRECOGNIZED_VERSIONdöner. Test ortamınızı bu yüzden ayrı tutun.deviceRecognitionVerdictbir dizidir. Varsayılan etiketMEETS_DEVICE_INTEGRITY’dir;MEETS_BASIC_INTEGRITYveMEETS_STRONG_INTEGRITYetiketleri yalnızca Play Console’dan açarsanız gelir (yukarıdaki koddakidegradeddalı bu yüzden opt-in gerektirir).MEETS_VIRTUAL_INTEGRITYise 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şikMEETS_DEVICE_INTEGRITYolmalı;STRONGseviyesini 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çinLICENSEDkontrolü yapın.environmentDetails(appAccessRiskVerdict,playProtectVerdict) vedeviceIntegrity.recentDeviceActivitygibi ek sinyaller Play Console’dan ayrıca açılır. Ekran kaydeden ya da kontrol eden uygulamalar (KNOWN_CAPTURING,UNKNOWN_CONTROLLINGgibi) 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ı
- Hafta 0, enstrümantasyon: İstemci kodunu yayınlayın ama sunucu yalnızca doğrulasın ve
integrity_eventstablosuna yazsın. Hiçbir isteği reddetmeyin. - 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
untrustedoranı yüksekse (örneğin belirli bir üreticinin cihazları) önce onu anlayın. - 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.
- 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ı:
DCErrorkodları, Play Integrity hata kodları - Doğrulama gecikmesi (p95) ve
decodeIntegrityTokenhata 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
developmentortamı kullanılır ve aaguidappattestdevelopolur (Apple’ın doğrulama adımları bu değeri verir, “Preparing to use the App Attest service” sayfası iseappattestsandboxder; 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_RECOGNIZEDdö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
developmentortamından gelen App Attest anahtarlarını kabul etmek. - Android’de imza sertifikası özetini kontrol etmemek; yalnızca paket adına güvenmek.
serverUnavailableya daINTEGRITY_TOKEN_PROVIDER_INVALIDgibi 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
productionanahtarları 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.
Kaynaklar
- Apple · Establishing your app’s integrity
- Apple · Validating apps that connect to your server
- Apple · Preparing to use the App Attest service
- Apple · Assessing fraud risk
- Apple · DCError.Code
- Apple · App Attest environment entitlement
- Apple PKI · App Attestation Root CA
- Android · Play Integrity API overview
- Android · Make a standard API request
- Android · Make a classic API request
- Android · Integrity verdicts
- Android · Play Integrity error codes
- Firebase · App Check
- OWASP MASVS · MASVS-RESILIENCE
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.