Sertaç Yıldırım saha notları

Ana sayfa → Teknik

“SMS Kodum Var, Güvendeyim” Deme: Para Çekmede Passkey ile Step-Up

Cumartesi 02:17. Piyasalar dün gece kapandı, dealing desk evde, back office pazartesi 09:00’da açılacak. Bir müşterinin web trader oturumundan yeni bir IBAN ekleniyor. Dört dakika sonra aynı oturumdan 4.800 $ para çekme talebi geliyor. JWT geçerli: imza doğru, exp dolmamış, sub doğru müşteri. Free margin yeterli, tutar otomatik onay sınırının altında. Talep onaylanıyor; TL karşılığı FAST ile saniyeler içinde yeni hesaba geçiyor.

Pazartesi 09:04’te müşteri arıyor: “Hesabımda para yok.” Token’ı, bir hafta önce kurduğu “PDF converter” tarayıcı eklentisi almış.

Özet
  • JWT kimliği değil, taşıyanı doğrular. Token kimin elindeyse istek onun.
  • Parolayı tekrar sormak ya da SMS kodu göndermek yetmez. Token’ı çalan çoğu zaman parolayı da almıştır; tek kullanımlık kod gerçek zamanlı oltalamayla aynı saniyede aktarılır.
  • Passkey alan adına bağlıdır ve özel anahtar sunucuya hiç gitmez. Oltalamaya dayanıklı olmasının sebebi bu.
  • Challenge tutara ve IBAN’a bağlanmalı. PSD2’nin dynamic linking dediği şey tam olarak bu: tutar ya da alıcı değişirse onay geçersiz.
  • Eşik tek sinyal değil ve sinyaller onay anında yeniden bakılır. Yeni IBAN, tanınmayan cihaz, yakın zamanda değişen parola ya da passkey — talep anında temiz olan hesap iki dakika sonra temiz olmayabilir.
  • Step-up, en zayıf arka kapısı kadar güçlüdür. IBAN ekleme, passkey kaydı ve API anahtarı oluşturma da aynı korumayı ister.

JWT neyi kanıtlar, neyi kanıtlamaz?

Platformda müşteri web trader’a ya da mobil uygulamaya giriş yapıyor, JWT alıyor. Grafik açmak, emir göndermek, açık pozisyonu izlemek, ekstre indirmek — hepsi aynı token’la. Çoğu işlem için bu doğru bir tasarım.

Backend bir JWT’yi doğruladığında aslında üç şey biliyor: token’ı biz ürettik (imza), süresi dolmadı (exp), içinde şu müşteri yazıyor (sub). Bilmediği tek şey ise en önemlisi.

Filtre geçti. Peki elimizde ne var?
// JWT filtresi basarili. Bildiklerimiz:
//   - Bu token'i biz imzaladik (signature)
//   - Suresi dolmadi (exp)
//   - Icinde "sub": "client_48213" yaziyor
// BILMEDIGIMIZ:
//   - Bu istegi gonderen gercekten client_48213 mu?
Client client = verifyJwt(request.header("Authorization"));

JWT bir bearer token. Bearer “taşıyan” demek: token’ı sunan, sahibi sayılır. Nakit para gibi düşün — cebinden düşen 100 $ kimin eline geçerse onun. Banknotun üstünde senin adın yazmıyor.

Trading platformlarında token çalmak sanıldığından kolay:

  • Infostealer ve kötü niyetli eklentiler. Finans kullanıcıları hedef alınarak yayılır; tarayıcının çerez ve token deposunu topluca toplar. Zararlı yazılım tarayıcının kendi deposunu okuduğu için HttpOnly çerez de burada kurtarmaz.
  • XSS. Web trader’a gömülü üçüncü parti bir grafik ya da haber widget’ı, localStorage’daki token’a tek satırla ulaşır.
  • Loglar. Authorization başlığını olduğu gibi yazan bir API gateway, bir hata izleme aracı, “production’daki bir hatayı bulmak için açtık” denen bir log seviyesi.
  • Açık kalan oturum. Ofisteki ortak bilgisayar, internet kafe, kilitlenmemiş ekran.
En sinsi tarafı

Çalınmış token’la yapılan istek, gerçek müşterinin isteğinden ayırt edilemez. İmza doğru, exp geçerli, biçim kusursuz. Sistem hata vermez; logda “200 OK” yazar. Hafta sonuysa kimse bakmaz bile — olay pazartesi sabahı müşteri hizmetlerinde patlar.

Kısa exp yardımcı olur ama çözmez: 15 dakikalık access token, IBAN ekleyip 4.800 $ çekmek için 10 dakika fazla. Refresh token da çoğu zaman aynı depodan, aynı anda çalınır. Stateless bir JWT’yi süresi dolmadan iptal etmek ise kara liste tutmak demek — yani stateless olmaktan vazgeçmek.

Bir de fintech’e özgü bir ağırlaştırıcı var: ödeme geri alınamaz. Kart işleminde chargeback var, hatalı bir emirde en azından karşı pozisyon var. FAST ile başka birinin hesabına geçmiş para için pratikte ikisi de yok.

JWT oturumu doğrulamak için iyi bir araç. Para çekmeyi doğrulamak için değil. Bunlar iki ayrı soru ve ikisine aynı cevabı vermek, 02:17’deki olayın ta kendisi.

Neden parolayı tekrar sormak ya da SMS kodu yetmiyor?

İlk refleks tanıdık: “Para çekerken parolayı bir daha soralım.” Ya da fintech’in klasiği: “SMS kodu gönderelim.” İkisi de bir şey kazandırıyor — ama ne kazandırdığı, saldırganın elinde ne olduğuna bağlı.

YöntemToken hırsızlığıGerçek zamanlı oltalama (kod aktarma)InfostealerSIM swap
Parolayı tekrar sormakDurdururDurdurmazÇoğu zaman durdurmaz (parola da tarayıcıda kayıtlı)Etkilenmez
SMS OTPDurdururDurdurmazDurdururDurdurmaz
TOTP (authenticator uygulaması)DurdururDurdurmazDurdururEtkilenmez
Passkey (user verification ile)DurdururDurdururBüyük ölçüde durdururEtkilenmez

Tek kullanımlık kodların ortak zayıf noktası şu: kod, nereye yazıldığını bilmez. Müşteri, reklamla karşısına çıkan sertacyildirim-yatirim.com adresindeki sahte giriş sayfasına 6 haneli kodu girer; saldırgan aynı saniye o kodu gerçek platforma aktarır. Kod geçerlidir, çünkü gerçekten geçerlidir.

Passkey neden farklı?

Kayıt sırasında cihaz bir anahtar çifti üretir. Özel anahtar sunucuya hiç gitmez: cihazda (Secure Enclave, TPM) ya da uçtan uca şifreli bir parola yöneticisinde durur; sunucuya sadece açık anahtar gider. Ve bu anahtar bir alan adına, yani bir rpId’ye bağlıdır: sertacyildirim.com.

Müşteri sahte siteye düşerse tarayıcı o passkey’i hiç önermez — alan adı eşleşmiyor. Kopyalanıp aktarılacak bir kod yok. Üstelik imzalanan verinin içinde origin alanı var ve bunu sayfanın JavaScript’i değil, tarayıcının kendisi dolduruyor.

Parola ve OTP kullanıcının dikkatine güvenir. Passkey tarayıcının origin kontrolüne güvenir. Gece 2’de dikkat yorulur; origin kontrolü yorulmaz.

Akış: para çekme talebinden FAST’e

Önce büyük resim. Bu sefer müşteri kendisi, kendi hesabına çekiyor:

Uçtan uca
Kullanici adi + parola
        |
Authentication -> JWT   (amr: ["pwd"])
        |
Grafik, emir, pozisyon, ekstre -----------------> JWT yeterli
        |
POST /withdrawals  (hesap: 2104551, 4.800 $, IBAN ...4417 / Ahmet Y.)
        |
Free margin kontrolu (hesap kilitli)
        |
Risk karari: esik, yeni IBAN, yeni cihaz/ulke, yakin tarihli guvenlik degisikligi
        |
Talep kaydi -> durum: PENDING_STEP_UP
        |
Sunucu challenge uretir -> SHA-256(nonce | tutar+IBAN ozeti), TTL 2 dk, tek kullanimlik
        |
Onay ekrani: "4.800,00 USD -> TR.. 4417 (Ahmet Y.)"  -> musteri butona basar
        |
Face ID / parmak izi / PIN -> cihaz ozel anahtarla imzalar
        |
Backend dogrular: origin (izin listesi), rpIdHash, challenge, UV bayragi, imza
        |
Risk sinyalleri YENIDEN -> free margin YENIDEN -> tutar rezerve -> APPROVED
        |
Odeme outbox (ayni transaction) -> banka entegrasyonu -> FAST / SWIFT

1. Risk kararı: JWT mi, step-up mı?

“1.000 $ ve üzeri” iyi bir başlangıç. Ama işlem başına uygulanırsa saldırganın cevabı bellidir: 999 $ × 5. Ve bir müşterinin birden fazla trading hesabı varsa, hesap başına eşik de aynı açık: 999 $ birinci hesaptan, 999 $ ikinciden.

Eşik müşteri seviyesinde, bütün hesaplarda, USD karşılığı üzerinden ve bir kayan pencere içinde hesaplanmalı. Başka bir müşteriye yapılan iç transfer de bu toplama girer; para platformdan çıkmasa bile müşteriden çıkıyor.

Eşik ise tek sinyal değil. 02:17’deki olayda tutar kadar önemli iki sinyal vardı: IBAN dört dakika önce eklenmişti ve sahibi müşteri değildi.

SinyalNeden şüpheli?Aksiyon
Son 24 saat toplamı ≥ 1.000 $EşikStep-up
Hedef IBAN son 48 saatte eklendiHesap ele geçirmede saldırganın ilk işi alıcı eklemekStep-up; eşik üstündeyse + 24 saat bekletme
IBAN sahibi müşteri adıyla eşleşmiyorÜçüncü kişiye çekimReddet / manuel inceleme
Tanınmayan cihaz ya da ülkeToken başka yerde kullanılıyorStep-up (eşiğin altında bile)
Son 24 saatte parola, e-posta ya da passkey değiştiHesap ele geçirmenin sırası tam olarak buÇekim kilidi
Risk kararı
static final BigDecimal ESIK_USD = new BigDecimal("1000");

Action karar(Client c, WithdrawalRequest r) {
    if (guvenlikDegisikligiVar(c.id, Duration.ofHours(24)))  return Action.CEKIM_KILITLI;
    if (!ibanSahibiEslesiyor(c, r.iban()))                   return Action.MANUEL_INCELEME;

    // Butun trading hesaplari, USD karsiligi, onaylanan + tamamlanan + ic transfer
    BigDecimal son24s = withdrawalRepo.sumUsdByClient(c.id, Duration.ofHours(24));
    boolean esikUstu  = son24s.add(r.amountUsd()).compareTo(ESIK_USD) >= 0;
    boolean yeniIban  = ibanRepo.age(c.id, r.iban()).compareTo(Duration.ofHours(48)) < 0;
    boolean yeniYer   = !deviceRepo.isKnown(c.id, r.deviceId())         // taninmayan cihaz
                     || !geoRepo.isKnownCountry(c.id, r.country());    // ya da ulke

    if (esikUstu && yeniIban)            return Action.STEP_UP_VE_BEKLET;
    if (esikUstu || yeniIban || yeniYer) return Action.STEP_UP;
    return Action.JWT_YETERLI;
}
Karar ve kayıt aynı kilit altında
@Transactional
Response cekimTalebi(Client c, WithdrawalRequest r) {
    // Ayni musterinin paralel istekleri birbirini gormeli:
    // es zamanli iki 600 $ istegi ikisi de "toplam 0" okursa esik atlanir.
    clientRepo.lock(c.id);                                              // SELECT ... FOR UPDATE

    // Hesap da kilitli okunur: musteri kilidi diger cekimlere karsi korur,
    // islem motorunun ayni anda pozisyon acmasina karsi korumaz.
    TradingAccount acc = accountRepo.lockAndFind(r.accountId(), c.id);  // FOR UPDATE
    if (acc.freeMargin().compareTo(r.amount()) < 0) return hata("INSUFFICIENT_FREE_MARGIN");

    Action a = karar(c, r);
    if (a == Action.CEKIM_KILITLI || a == Action.MANUEL_INCELEME) return beklet(c, r, a);

    if (a == Action.JWT_YETERLI) {
        // En sik calisan yol bu: cekimlerin cogu esigin altinda.
        // Rezerv ve outbox burada da var; step-up yolundakiyle birebir ayni.
        Withdrawal w = withdrawalRepo.create(c.id, r, Status.APPROVED);
        acc.reserve(r.amount());
        payoutOutbox.add(w.id, w.releaseAt());
        return ok(w);
    }

    Withdrawal w = withdrawalRepo.create(c.id, r, Status.PENDING_STEP_UP);
    if (a == Action.STEP_UP_VE_BEKLET) w.setReleaseAt(now().plusHours(24));  // onaylansa bile odeme bekler
    return stepUpGerekli(w.id);   // 403 { "status": "STEP_UP_REQUIRED", "withdrawalId": 7731 }
}

Üç ayrıntı:

  • Kilit olmadan eşik bir race condition’dır. Kontrol ile kayıt arasına ikinci bir istek sığar. Kontrol ve kayıt aynı veritabanındaysa FOR UPDATE yeter; servisin kaç kopya çalıştığı fark etmez, hepsi aynı kilidi bekler. Veri birden fazla depoya dağılmışsa — sharding, ayrı veritabanları, durumun Redis’te tutulması — o zaman dağıtık kilit gerekir.
  • Eşik altı yol da rezerv ve outbox’tan geçer. Bu en sık çalışan yol; burada rezervi atlarsan aynı para hem bankaya gider hem hesapta margin olarak durur, outbox’ı atlarsan talep APPROVED görünür ama ödeme mesajı hiç yazılmaz. İkisinin de ne demek olduğuna aşağıda geleceğiz.
  • Neden 401 değil? Web trader 401 alınca “oturum düştü” deyip giriş ekranına atar; açık grafikler, çizimler, emir penceresi kaybolur. İstemcinin “oturum geçerli ama bu işlem için daha güçlü doğrulama lazım” durumunu ayırt edebilmesi gerekiyor. Standart bir yol istersen RFC 9470 bunu 401 + WWW-Authenticate başlığında error="insufficient_user_authentication" ile tanımlıyor. Hangi durum kodunu seçtiğinden çok, istemcinin ikisini karıştırmaması önemli.

2. Challenge: sunucu üretir, tutara ve IBAN’a bağlar

Challenge üretimi
StepUpChallenge challengeUret(Client c, long withdrawalId) {
    Withdrawal w = withdrawalRepo.find(withdrawalId, c.id, Status.PENDING_STEP_UP);

    byte[] nonce = new byte[32];
    SECURE_RANDOM.nextBytes(nonce);                     // CSPRNG, tahmin edilemez

    // digest    = SHA-256(hesapId | tutar | para birimi | iban)
    // challenge = SHA-256(nonce | digest)  -> cihazin imzaladigi veri islemi de kapsiyor
    byte[] challenge = sha256(concat(nonce, w.digestBytes()));

    redis.set("stepup:" + w.id,
              json(new PendingChallenge(c.id, w.id, base64url(nonce), w.digest())),
              SetArgs.ex(120));                         // TTL: 2 dakika

    var options = new PublicKeyCredentialRequestOptions(
        challenge,
        RP_ID,                                          // "sertacyildirim.com"
        passkeyRepo.credentialIds(c.id),                // allowCredentials: SADECE bu musterinin anahtarlari
        "required",                                     // userVerification: biyometrik veya PIN
        60_000                                          // timeout
    );
    // Onay ekrani SUNUCUNUN degerlerini gosterir: tutar, para birimi, maskeli IBAN, hesap sahibi
    return new StepUpChallenge(options, w.ozet());
}

Her satırın bir sebebi var:

  • challenge = SHA-256(nonce | digest). Rastgele bir challenge üretip özeti yalnızca Redis’te saklasan da talebin değişmesini engellersin; ama o zaman bağ sunucunun “bu challenge şu talebe aitti” kaydına dayanır, cihazın imzaladığı veri tutarı içermez. Özeti challenge’ın içine koyunca imza kriptografik olarak tutarı ve IBAN’ı da kapsar: altı ay sonra bir denetimde “müşteri tam olarak bu tutarı imzaladı” gösterilebilir. Müşteri 1.200 $ onaylar; 4.800 $ gitmez, başka bir IBAN’a hiç gitmez.
  • allowCredentials sadece JWT’deki müşterinin credential’larını içerir. Başka bir müşterinin passkey’iyle imzalanmış bir assertion kabul edilmemeli — bunu backend’de ayrıca kontrol edeceğiz.
  • TTL kısa. Challenge’ın işi tazelik: “Bu imza şimdi, bu talep için üretildi.”
  • Yanıt iki parça: WebAuthn seçenekleri ve çekim özeti. Onay ekranı tutarı istemcinin hafızasından değil, sunucunun döndüğünden çizer.
Regülasyon da aynı şeyi söylüyor

Avrupa’da PSD2’nin Strong Customer Authentication (SCA) kuralları, ödeme işlemlerinde dynamic linking ister: doğrulama kodu tutara ve alıcıya özgü olmalı, biri değişirse geçersiz sayılmalı. Özeti challenge’a katmak bunun uygulaması; imzanın kendisi işleme bağlanıyor. Tutarın authenticator ekranında gösterilmemesi ayrı bir problem, ona aşağıda SPC ile geleceğiz. Kurumunun hangi regülasyona tabi olduğu uyum ekibinin konusu; ama hangi ülkede olursan ol, tasarım olarak doğru olan bu.

Sık yapılan hata

Challenge’ı istemci tarafında üretmek, ya da Date.now() gibi tahmin edilebilir bir değer kullanmak. Sunucu challenge’ı üretip saklamıyorsa, dün yakalanmış bir imza bugün tekrar kullanılabilir (replay). Challenge bir nonce’tur; nonce’ı veren de doğrulayan da sunucu olmalı.

3. Onay ekranı: müşteri neyi onayladığını görmeli

İstemci tarafı
// Sunucu yaniti iki parca: WebAuthn secenekleri + cekim ozeti
const { publicKey, withdrawal } = await api.post(`/withdrawals/${withdrawalId}/challenge`);

// Passkey penceresi tutari GOSTERMEZ. Tutar ve IBAN bizim onay ekranimizda,
// butonun hemen ustunde, sunucudan gelen degerlerle cizilmeli.
renderOnay(`${withdrawal.amount} ${withdrawal.currency} -> ${withdrawal.maskedIban} (${withdrawal.holderName})`);

// Musteri tutari okuyup butona basmadan passkey penceresi acilmasin.
// (Tarayicilar WebAuthn cagrisi icin zaten bir kullanici etkilesimi bekler.)
await onayButonunaBasildi();

const assertion = await navigator.credentials.get({
  publicKey: {
    challenge: base64urlToBuffer(publicKey.challenge),
    rpId: publicKey.rpId,
    allowCredentials: publicKey.allowCredentials.map((c) => ({
      type: "public-key",
      id: base64urlToBuffer(c.id),
    })),
    userVerification: "required",
    timeout: 60000,
  },
});

await api.post(`/withdrawals/${withdrawalId}/confirm`, {
  credentialId: assertion.id,
  clientDataJSON: bufferToBase64url(assertion.response.clientDataJSON),
  authenticatorData: bufferToBase64url(assertion.response.authenticatorData),
  signature: bufferToBase64url(assertion.response.signature),
});

Modern tarayıcılarda PublicKeyCredential.parseRequestOptionsFromJSON() ve assertion.toJSON() bu base64url dönüşümlerini senin yerine yapıyor; eski tarayıcıları da destekliyorsan yardımcı fonksiyonlar kalsın.

Müşterinin gördüğü sistem penceresi kısa: “sertacyildirim.com için passkey kullanılsın mı?” ve Face ID. Tutar orada yok. O yüzden hemen üstündeki onay ekranında “4.800,00 USD → TR.. 4417 (Ahmet Y.)” büyük ve net yazmalı ve Face ID penceresi müşteri butona basmadan açılmamalı. IBAN’ın son dört hanesi ve hesap sahibinin adı, müşterinin “bu benim hesabım değil” diyebileceği tek an.

4. Backend: imzayı doğrula, riski ve free margin’i yeniden kontrol et

Doğrulama: sıra önemli, her adım bir saldırıyı kapatıyor
static final Set<String> IZINLI_ORIGINLER = Set.of(
    "https://sertacyildirim.com",
    "https://trader.sertacyildirim.com",                 // web trader alt alan adinda; rpId ayni
    "android:apk-key-hash:<uygulama-imza-hash>");        // Android yerel uygulama: origin https degil

Response onayla(Client c, long withdrawalId, AssertionDto a) {
    // (istemciye hepsi ayni hatayi doner: VERIFICATION_FAILED. Ayrinti sadece logda.)

    // 1. Challenge'i al ve AYNI ANDA sil (GETDEL). GET ve DEL ayri olursa
    //    iki paralel istek ayni challenge'i kullanabilir.
    PendingChallenge pc = redis.getdel("stepup:" + withdrawalId);
    if (pc == null || !pc.clientId.equals(c.id)) return reddet("challenge");

    // 2. Bu credential bu musteriye mi ait?
    Passkey p = passkeyRepo.find(a.credentialId);
    if (p == null || !p.clientId.equals(c.id)) return reddet("credential");

    // 3. clientDataJSON: tarayici neyi imzalattigini soyluyor
    ClientData cd = ClientData.parse(a.clientDataJSON);
    if (!cd.type.equals("webauthn.get"))                    return reddet("type");
    byte[] beklenen = sha256(concat(base64urlDecode(pc.nonce), pc.digestBytes()));  // nonce + islem ozeti
    if (!constantTimeEquals(cd.challengeBytes, beklenen))   return reddet("challenge");
    if (!IZINLI_ORIGINLER.contains(cd.origin))              return reddet("origin");       // <- oltalama burada yakalanir
    if (Boolean.TRUE.equals(cd.crossOrigin))                return reddet("crossOrigin");  // iframe icinden cagri

    // 4. authenticatorData: cihaz ne soyluyor
    AuthenticatorData ad = AuthenticatorData.parse(a.authenticatorData);
    if (!Arrays.equals(ad.rpIdHash, sha256(RP_ID))) return reddet("rpIdHash");
    if (!ad.userPresent())  return reddet("UP");
    if (!ad.userVerified()) return reddet("UV");     // <- "cihaz orada" degil, "sahibi orada"

    // 5. Imza: authenticatorData || SHA-256(clientDataJSON), kayitli acik anahtarla
    byte[] signedData = concat(a.authenticatorDataRaw, sha256(a.clientDataJSONRaw));
    if (!verifyCoseSignature(p.publicKey, signedData, a.signature)) return reddet("signature");

    // 6. signCount: klonlanmis authenticator sinyali. Senkronize passkey'lerde genelde hep 0 gelir.
    if ((ad.signCount != 0 || p.signCount != 0) && ad.signCount <= p.signCount) {
        alarm("possible cloned authenticator", p.id);
        return reddet("signCount");
    }
    passkeyRepo.updateSignCount(p.id, ad.signCount);

    // 7. Kimlik dogrulama tamam. Simdi risk ve para tarafi (ayri bean'de, asagiya bak).
    return onayServisi.onaylaVeRezerveEt(withdrawalId, c.id, pc.digest, p) ? ok() : reddet("status");
}

Origin satırı tek bir değerle birebir karşılaştırma olsaydı iki şeyi kırardı: web trader trader.sertacyildirim.com gibi bir alt alan adındaysa passkey çalışır (rpId aynı) ama kontrol reddeder; Android uygulaması ise origin’i https:// değil android:apk-key-hash: biçiminde gönderir. Doğrusu izin listesi. crossOrigin alanı da bedava bir sinyal: true ise çağrı bir iframe içinden gelmiş demek ve bizim sayfamız bunu hiç yapmıyor.

Kimlik doğrulama tamam ama iş bitmedi. Challenge ile onay arasında iki dakika geçti. O iki dakikada iki şey değişmiş olabilir: piyasa açıksa açık pozisyonlar hareket etti ve talep anında yeterli olan free margin şimdi yeterli olmayabilir; hesabın kendisi de değişmiş olabilir — birazdan neden.

Risk yeniden, rezerv ve ödeme mesajı: tek transaction
@Service   // AYRI bir bean: onayla() ile ayni sinifta olsa @Transactional devreye girmezdi (asagiya bak)
class OnayServisi {

  @Transactional
  boolean onaylaVeRezerveEt(long withdrawalId, long clientId, String digest, Passkey p) {
      Withdrawal w = withdrawalRepo.lockAndFind(withdrawalId, clientId);   // FOR UPDATE
      if (w.status != Status.PENDING_STEP_UP || !w.digest.equals(digest)) return false;

      // Risk sinyalleri ONAY aninda yeniden: talep aninda temiz olan hesap arada degismis olabilir
      if (guvenlikDegisikligiVar(clientId, Duration.ofHours(24))) { w.hold("SECURITY_CHANGE"); return false; }
      if (p.createdAt().isAfter(now().minusHours(48)))             { w.hold("NEW_CREDENTIAL");  return false; }  // yeni passkey: bekleme suresi

      TradingAccount acc = accountRepo.lockAndFind(w.accountId);           // FOR UPDATE
      // Free margin SIMDI yeterli mi? (anlik equity - kullanilan margin)
      if (acc.freeMargin().compareTo(w.amount) < 0) {
          w.reject("INSUFFICIENT_FREE_MARGIN");                            // musteriye bildirim gider
          return false;
      }

      acc.reserve(w.amount);                    // bu tutar artik yeni pozisyon icin margin olarak kullanilamaz
      w.approve();
      payoutOutbox.add(w.id, w.releaseAt());    // onay commit olduysa odeme mesaji da commit oldu; ya ikisi ya hicbiri
      return true;
  }
}

Risk sinyallerini onay anında tekrarlamayan ilk sürümde şu sıralama açıktı: saldırgan önce çekim talebini oluşturur — o anda son 24 saatte güvenlik değişikliği yoktur, talep PENDING_STEP_UP olur. Sonra kendi passkey’ini kaydeder (müşterinin hiç passkey’i yoksa e-posta doğrulamasıyla; infostealer e-posta oturumunu da almış olabilir) ve talebi bu yeni passkey’le onaylar. “Son 24 saatte passkey değişti → çekim kilidi” kuralı talep anında kontrol edildiği için hiç devreye girmez. Kural onay anında da çalışınca ve passkey’in yaşı 48 saatten küçükse talep beklemeye alınınca bu yol kapanıyor.

Rezerv atlanırsa şu olur: talep onaylanır, ödeme kuyruğa girer, müşteri aynı parayla yeni bir pozisyon açar. Para bankaya giderken hesapta margin olarak da duruyor. Outbox atlanırsa tersi olur: talep APPROVED görünür ama ödeme mesajı hiç yazılmamıştır.

@Transactional tuzağı

Kod Spring görünümünde ve Spring’in varsayılan proxy tabanlı yapısında @Transactional, aynı sınıf içinden yapılan çağrıda devreye girmez. onayla() ile onaylaVeRezerveEt() aynı sınıfta olsaydı FOR UPDATE kilitleri autocommit ile anında bırakılır, onay ile outbox atomik olmaz ve “ya ikisi ya hiçbiri” garantisi sessizce kaybolurdu — hata yok, log yok, test ortamında çalışıyor. Ayrı bir bean ya da TransactionTemplate. “Arkasında ne olduğunu gör” derken kastettiğim türden bir şey.

Bunu production’da elle yazma. webauthn4j ya da Yubico’nun java-webauthn-server’ı gibi olgun bir kütüphane CBOR/COSE ayrıştırmasını ve imza kontrollerinin çoğunu yapıyor. Yukarıdaki kod, kütüphanenin arkasında ne olduğunu görmek için — çünkü kütüphaneyi yanlış ayarlayınca hangi kontrolün sessizce kapandığını bilmen gerekiyor.

İki kontrol özellikle atlanıyor:

  • UV bayrağı backend’de kontrol edilmeli. Seçeneklerde userVerification: "required" göndermek bir istek, garanti değil. Web trader’daki bir widget üzerinden XSS varsa saldırganın script’i aynı çağrıyı "discouraged" ile yapabilir; bazı güvenlik anahtarları o durumda PIN’siz, tek dokunuşla imzalar. Kararı istemci değil, imzalı authenticatorData içindeki bayrak verir.
  • Credential sahipliği kontrol edilmeli. İmza geçerli olabilir — ama başka bir müşterinin passkey’iyle. “İmza geçerli” ile “bu müşterinin imzası” aynı şey değil; JWT’de öğrendiğimiz dersin aynısı.

Ödeme tüketicisi de idempotent olmalı: aynı outbox mesajının iki kez işlenmesi, aynı FAST transferinin iki kez gönderilmesi demek. Konunun o tarafı Mesaj Kaç Kez Gelir? yazısında.

Arka kapılar: ön kapıya çelik kilit, pencere açık

Saldırganın elinde JWT var, passkey yok. Çekimi onaylayamıyor. Güzel.

Peki Ayarlar → Güvenlik → Passkey ekle ekranına gidip kendi passkey’ini kaydederse? O endpoint sadece JWT istiyorsa, 30 saniye sonra 4.800 $’lık çekimi kendi telefonuyla, kusursuz bir imzayla onaylar. Backend’in bütün kontrolleri geçer — çünkü artık gerçekten kayıtlı bir credential. Yukarıdaki 48 saatlik bekleme onu yavaşlatır, durdurmaz; durduran şey o endpoint’in de step-up istemesi.

Trading platformunda bu listeye fintech’e özgü birkaç kapı daha ekleniyor:

İşlemStep-up yoksa saldırgan ne yapar?
Yeni IBAN / kripto cüzdan adresiParanın nereye gideceğini o belirler; sonraki bütün çekimler oraya
Passkey kaydıKendi credential’ını ekler, sonra her şeyi “meşru” şekilde onaylar
Passkey silmeMüşterinin credential’ını siler, sistemi yedek yönteme zorlar
Başka müşteriye iç transferParayı bankaya hiç çıkarmadan kendi hesabına alır, oradan çeker
API anahtarı oluşturmaÇekim ya da transfer yetkili bir anahtarla bütün arayüz kontrollerini atlar
E-posta / telefon değişikliğiÇekim bildirimlerini ve kurtarma kanalını ele geçirir
Parola değişikliğiAsıl müşteriyi hesabın dışında bırakır
Hesap kurtarma“Telefonumu kaybettim” diyerek bütün zinciri atlar

API anahtarı satırı özellikle sinsi. Algoritmik işlem yapan müşteriler için açılan bu kapı çoğu zaman “developer özelliği” olarak ayrı bir ekipte yaşar ve güvenlik incelemesinden geçmez. API anahtarları varsayılan olarak çekim yetkisiz olmalı, IP listesine bağlı olmalı ve çekim yetkisi ayrı bir step-up ile açılmalı.

İki zor durum kalıyor:

  • Müşterinin hiç passkey’i yoksa? İlk passkey’i passkey ile onaylatamazsın. Burada yaygın yaklaşım e-posta doğrulaması + bildirim + bekleme süresi: yeni kaydedilen passkey ya da IBAN ile ilk 24–48 saat büyük çekim yapılamaz. Yukarıdaki NEW_CREDENTIAL satırı tam olarak bu. Birçok borsa ve trading platformu, güvenlik ayarı değiştiğinde çekimleri tam da bu yüzden bir süre kilitliyor.
  • Yedek yöntem. “Passkey’im yanımda değil, SMS ile onaylayayım” seçeneği varsa, saldırgan passkey’le hiç uğraşmaz. O andan itibaren step-up’ın gücü SMS’in gücüdür.
Güvenlik zinciri en sağlam halkası kadar değil, en kolay atlanan kapısı kadar güçlüdür.

Yükseltilmiş oturum tuzağı

Kolay yol şu: passkey doğrulandıktan sonra amr: ["pwd", "hwk"] taşıyan yeni bir JWT ver, 10 dakika boyunca bütün hassas işlemler serbest olsun. Müşteri IBAN ekleyip hemen çekim yapacaksa iki kez Face ID’ye bakmasın.

Kulağa makul geliyor. Ama iki yaklaşımın onayladığı şey farklı:

 Zaman penceresi (10 dk yükseltilmiş token)İşleme bağlı onay
Onaylanan şey“Bu kişi 10 dakika önce buradaydı”“Bu kişi bu tutarı bu IBAN’a onayladı”
Web trader’da XSS varsaPencere boyunca saldırganın script’i her şeyi yapar, müşteriye hiç görünmedenSaldırgan her çekim için müşteriyi ayrı bir Face ID’ye kandırmak zorunda (man-in-the-browser, aşağıda)
Kullanıcı deneyimiArt arda işlemlerde tek onayHer hassas işlemde onay
Doğru yerEkstre indirme, vergi belgesi, ayarları görüntülemeÇekim, yeni IBAN, passkey kaydı, API anahtarı

XSS satırı önemli ve iki taraf için de dürüst okunmalı. Yükseltilmiş token meşru müşterinin tarayıcısına gider; sayfada kötü niyetli bir script varsa 10 dakika boyunca o token’la istediği her şeyi yapar, müşteri hiçbir şey görmez. İşleme bağlı onay bunu ortadan kaldırmaz — aynı origin’deki script ekranda müşterinin çekimini gösterip arka planda kendi IBAN’ına talep açabilir, origin kontrolü de geçer çünkü origin gerçekten doğrudur. Fark şu: saldırgan artık her çekim için müşteriyi ayrı bir onaya kandırmak zorunda; çalınacak genel bir yetki yok, tek bir çekime bağlı tek kullanımlık bir imza var.

Para söz konusuysa ikincisi. Birkaç saniyelik Face ID, bir saldırı penceresinden ucuz.

Passkey neyi çözmez?

Passkey güçlü bir araç ama sihirli değnek değil. Üç sınırını bilmek, üzerine ne koyman gerektiğini söylüyor.

Man-in-the-browser. Müşterinin tarayıcısındaki kötü niyetli eklenti, ekranda “TR.. 4417 (kendi hesabım)” gösterirken arka planda saldırganın IBAN’ıyla istek oluşturabilir. Bankacılık truva atlarının yıllardır yaptığı şey tam olarak bu. Passkey penceresi tutarı göstermediği için müşteri Face ID’ye bakar ve onaylar — challenge saldırganın çekimine bağlıdır ve imza kusursuzdur. Önlemler:

  • Bant dışı bildirim. Mobil uygulamaya push: “4.800 $, TR.. 9021 (Mehmet K.) hesabına. Siz değilseniz dokunun.” Tarayıcıyı ele geçiren, telefondaki bildirimi değiştiremez.
  • Yeni alıcı bekletmesi. Daha önce hiç para gönderilmemiş bir IBAN’a büyük çekim birkaç saat bekler ve müşteri tarafından iptal edilebilir. FAST’in hızı burada bir özellik değil, bir risk.
  • Hesap sahibi adı kontrolü. Çekim sadece müşterinin kendi adına olan hesaba. Yukarıdaki bildirimde müşteri Ahmet Y. iken “Mehmet K.” yazması, tek başına birçok saldırıyı görünür kılar — ve MANUEL_INCELEME kuralı bunu bildirime bile bırakmaz, talep daha ilk adımda durur.
  • Secure Payment Confirmation (SPC). Tutarı tarayıcının kendi penceresinde gösteren bir WebAuthn uzantısı. Tam olarak bu problem için tasarlandı, ama tarayıcı desteği hâlâ sınırlı.

Senkronize passkey = bulut hesabı. Passkey iCloud Keychain ya da Google Password Manager ile eşitleniyorsa özel anahtar (şifreli olarak) cihazdan çıkar ve o bulut hesabını ele geçiren yeni bir cihazda passkey’e erişebilir. Risk ortadan kalkmaz, telefondan bulut hesabına taşınır. authenticatorData içindeki BE (backup eligible) ve BS (backup state) bayrakları credential’ın senkronize olup olmadığını söylüyor; kurumsal hesaplar ya da çok büyük çekimler için cihaza bağlı credential şartı koyabilirsin.

Sosyal mühendislik. “Merhaba, platformun güvenlik ekibinden arıyorum, hesabınızda şüpheli giriş var, korumak için gelen onayı kabul edin.” Müşteri kendi eliyle, kendi Face ID’siyle onaylar. Teknik bir çözümü yok; ama onay ekranındaki “Sizi telefonla arayıp onay isteyen biri varsa bu bir dolandırıcılıktır” satırı, hesap sahibi adı ve bekleme süresi burada da işe yarıyor.

Ne izlemeli?

Step-up’ın sağlığını gösteren metrikler
  • Step-up tamamlanma oranı — ani düşüş ya bir arayüz problemi, ya da elinde token olup passkey’i olmayan birileri.
  • Müşteri başına başarısız doğrulama — birkaç tane normal, onlarca değil.
  • Eşiğin hemen altındaki çekimler — 950–999 $ aralığında yığılma bir parçalama denemesidir; müşteri seviyesinde ve hesaplar arası bak.
  • Yeni IBAN → çekim süresi — IBAN eklendikten sonraki bir saat içinde gelen çekim ayrı bir alarm hak ediyor.
  • Onay anında beklemeye düşen talepler (SECURITY_CHANGE, NEW_CREDENTIAL) — her biri ya bir saldırı denemesi ya da sabırsız bir müşteri; ikisi de görülmeli.
  • Mesai dışı ve hafta sonu çekimleri — kimsenin bakmadığı saatler saldırganın da bildiği saatler.
  • Yetersiz free margin ile reddedilen onaylar — step-up ile onay arasındaki sürenin çok uzadığını gösterir.
  • signCount gerilemesi — sıfırdan farklı sayaç geriye gittiyse doğrudan alarm.
  • Çekim yetkili API anahtarı oluşturma — nadir bir olay olmalı; her biri görünür olmalı.

Kontrol listesi

Her ödeme ve güvenlik değişikliği için
  • Eşik müşteri seviyesinde, bütün hesaplarda, USD karşılığı ve kayan pencere üzerinden mi?
  • İç transferler eşik toplamına dahil mi?
  • Karar sadece tutara mı bakıyor, yoksa yeni IBAN, yeni cihaz/ülke ve yakın tarihli güvenlik değişikliği de sinyal mi?
  • Risk sinyalleri onay anında yeniden değerlendiriliyor mu? Yeni passkey için bekleme süresi kodda var mı?
  • Eşik kontrolü eş zamanlı isteklere karşı kilitli mi? Hesap da kilitli okunuyor mu?
  • Eşik altı yol da rezerv + outbox’tan geçiyor mu?
  • Challenge sunucu tarafında, CSPRNG ile mi üretiliyor ve tek kullanımlık mı (atomik GETDEL)?
  • Challenge tutar + IBAN özetini kapsıyor mu, yani imza işleme kriptografik olarak bağlı mı (dynamic linking)?
  • Onay ekranı tutarı, IBAN’ın son hanelerini ve hesap sahibinin adını sunucu değerleriyle gösteriyor ve passkey penceresi butondan sonra mı açılıyor?
  • Backend type, rpIdHash ve UV bayrağını kontrol ediyor; origin izin listesi web trader alt alan adını ve mobil uygulamayı kapsıyor, crossOrigin reddediliyor mu?
  • Credential’ın JWT’deki müşteriye ait olduğu doğrulanıyor mu?
  • Onay anında free margin yeniden kontrol ediliyor ve tutar rezerve ediliyor mu?
  • Onay ve ödeme mesajı aynı transaction’da mı (outbox) ve @Transactional gerçekten devreye giriyor mu (sınıf içi çağrı değil)?
  • Yeni IBAN, passkey kaydı/silme, API anahtarı, e-posta/telefon değişikliği ve hesap kurtarma aynı korumada mı?
  • Passkey’i atlayan bir yedek yöntem var mı? Varsa gücü neyse step-up’ın gücü o.
  • Büyük çekimlerde bant dışı bildirim gidiyor mu?

Sonuç

O cumartesi 02:17’de JWT hiçbir kuralı çiğnemedi. Geçerliydi, imzası doğruydu, süresi dolmamıştı. Free margin yeterliydi, tutar sınırın altındaydı. Sistem her kontrolü doğru yaptı — problem, “geçerli token” ile “gerçek müşteri”yi aynı şey saymaktı.

Aynı gece şunlar olsaydı: IBAN’ın sahibi Mehmet K., müşteri Ahmet Y. olduğu için talep daha ilk adımda manuel incelemeye düşerdi. O kural olmasaydı bile yeni IBAN endpoint’i passkey isteseydi, saldırgan orada dururdu. Durmasaydı bile çekim “yeni IBAN + eşik” sinyaliyle step-up’a ve 24 saat bekletmeye düşerdi. Onu da aşsaydı, müşterinin telefonuna “4.800 $, Mehmet K. hesabına” bildirimi giderdi. Tek bir kontrol değil; her biri bir öncekinin kaçırdığını yakalayan katmanlar — defense in depth.

Kod tarafı birkaç yüz satır. Asıl iş, o satırların etrafındaki kapıları — yeni IBAN endpoint’ini, API anahtarlarını, yedek yöntemi, 999 $’lık çekimleri — aynı ciddiyetle kapatmak.

Çünkü token bir string’dir; kopyalanır, loglanır, çalınır. Passkey ise imza üretir ama özel anahtarı asla göndermez. Ödemede sorulacak soru tam olarak bu: “Token’ın var mı?” değil, “Anahtar sende mi?”