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ış.
- 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.
// 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.
Authorizationbaş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.
Ç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.
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öntem | Token hırsızlığı | Gerçek zamanlı oltalama (kod aktarma) | Infostealer | SIM swap |
|---|---|---|---|---|
| Parolayı tekrar sormak | Durdurur | Durdurmaz | Çoğu zaman durdurmaz (parola da tarayıcıda kayıtlı) | Etkilenmez |
| SMS OTP | Durdurur | Durdurmaz | Durdurur | Durdurmaz |
| TOTP (authenticator uygulaması) | Durdurur | Durdurmaz | Durdurur | Etkilenmez |
| Passkey (user verification ile) | Durdurur | Durdurur | Büyük ölçüde durdurur | Etkilenmez |
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.
Akış: para çekme talebinden FAST’e
Önce büyük resim. Bu sefer müşteri kendisi, kendi hesabına çekiyor:
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.
| Sinyal | Neden şüpheli? | Aksiyon |
|---|---|---|
| Son 24 saat toplamı ≥ 1.000 $ | Eşik | Step-up |
| Hedef IBAN son 48 saatte eklendi | Hesap ele geçirmede saldırganın ilk işi alıcı eklemek | Step-up; eşik üstündeyse + 24 saat bekletme |
| IBAN sahibi müşteri adıyla eşleşmiyor | Üçüncü kişiye çekim | Reddet / manuel inceleme |
| Tanınmayan cihaz ya da ülke | Token başka yerde kullanılıyor | Step-up (eşiğin altında bile) |
| Son 24 saatte parola, e-posta ya da passkey değişti | Hesap ele geçirmenin sırası tam olarak bu | Çekim kilidi |
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;
}
@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 UPDATEyeter; 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
APPROVEDgö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-Authenticatebaşlığındaerror="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
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. -
allowCredentialssadece 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.
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.
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
// 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
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.
@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.
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ıauthenticatorDataiç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:
| İşlem | Step-up yoksa saldırgan ne yapar? |
|---|---|
| Yeni IBAN / kripto cüzdan adresi | Paranı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 silme | Müşterinin credential’ını siler, sistemi yedek yönteme zorlar |
| Başka müşteriye iç transfer | Parayı 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ği | Ası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_CREDENTIALsatı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.
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 varsa | Pencere boyunca saldırganın script’i her şeyi yapar, müşteriye hiç görünmeden | Saldırgan her çekim için müşteriyi ayrı bir Face ID’ye kandırmak zorunda (man-in-the-browser, aşağıda) |
| Kullanıcı deneyimi | Art arda işlemlerde tek onay | Her hassas işlemde onay |
| Doğru yer | Ekstre 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_INCELEMEkuralı 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 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
- 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,rpIdHashve UV bayrağını kontrol ediyor; origin izin listesi web trader alt alan adını ve mobil uygulamayı kapsıyor,crossOriginreddediliyor 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
@Transactionalgerç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?”