feat: EKAP imzalama anahtari rotasyona girince kendini toparlasin

Anahtar sabit kodluydu (EKAP_R8FACT_KEY ile gecilebiliyordu). EKAP anahtari
donemsel degistiriyor; degistigi anda ihale havuzu tamamen karariyor ve birinin
elle yeni anahtari bulup env'e yazmasi gerekiyordu.

Artik kimlik dogrulama hatasindan sonra anahtar portalin kendi paketinden
kesfediliyor. Zincir canli EKAP'ta dogrulandi:
  /ekap/search      → <script src="main.<hash>.js">
  main.<hash>.js    → webpack chunk haritasi; 2076 ozel olarak "common" adiyla
                      yayimlaniyor: y=>(2076===y?"common":y)+"."+{...}
  common.<hash>.js  → r8fact:"<32 baytlik anahtar>"
Kesfedilen deger su anki sabit anahtarla birebir ayni cikti — zincir dogru.

- Kesif YALNIZCA yedek: normalde sabit/env anahtar kullanilir, kesif ancak
  401/403/5xx sonrasi ve istek basina BIR KEZ denenir (gercek sunucu hatasinda
  her denemede portalden uc dosya indirmeyelim)
- 5xx de tetikliyor: EKAP gecersiz anahtari bazen 401, bazen 500 ile bildiriyor
  (imza cozulemeyince sifre cozme katmani patliyor); yalnizca 401'e bakmak
  rotasyonun yarisini kacirirdi
- Basari 1 sa, basarisizlik 5 dk onbellekleniyor
- Anahtar 32 bayt degilse kullanilmiyor — sema degismis demektir, korlemesine
  denemek yerine mevcut anahtarla devam edilir
- SPA fallback tuzagi: bilinmeyen yol HTTP 200 + index.html donduruyor; JS
  beklenen isteklerde HTML gelirse hata veriliyor (sessiz basarisizlik olmasin)

Sahte rotasyon testi: bozuk anahtarla EKAP 500 dondu → kesif devreye girdi →
dogru anahtar bulundu → tekrar denendi → arama basarili (860 ms).

Bulgu kaynagi: saidsurucu/ihale-mcp taramasinda gorulen imza semasi ve
firatbadur/ihaletakip-api'nin belgeledigi rotasyon davranisi. Ilgili depo
lisanssiz oldugu icin kod kopyalanmadi, uygulama sifirdan yazildi.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 20:04:11 +03:00
co-authored by Claude Opus 5
parent ff797f5a91
commit 9c142fa0f8
2 changed files with 102 additions and 3 deletions
+77
View File
@@ -0,0 +1,77 @@
/**
* EKAP imzalama anahtarının çalışma anında keşfi.
*
* NEDEN: güvenlik başlıkları (X-Ekap-Sec-*) AES anahtarıyla üretiliyor ve EKAP
* bu anahtarı dönemsel olarak değiştiriyor. Anahtar sabit kodlu kaldığı sürece
* her rotasyonda ihale havuzu tamamen kararıyor ve birinin elle yeni anahtarı
* bulup env'e yazması gerekiyor.
*
* ZİNCİR (canlı EKAP'ta doğrulandı):
* /ekap/search → <script src="main.<hash>.js">
* main.<hash>.js → webpack chunk haritası; 2076 özel olarak "common"
* adıyla yayımlanıyor: y=>(2076===y?"common":y)+"."+{...}
* common.<hash>.js → r8fact:"<32 baytlık anahtar>"
*
* Bu yol YALNIZCA yedektir: normal durumda sabit/env anahtar kullanılır,
* keşif ancak EKAP kimlik doğrulama hatası verdiğinde denenir.
*/
const BASE = 'https://ekapv2.kik.gov.tr';
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36';
const BASARI_TTL = 60 * 60 * 1000; // 1 saat
const HATA_TTL = 5 * 60 * 1000; // başarısız keşfi 5 dk tekrar deneme
const MAX_BAYT = 4 * 1024 * 1024;
let onbellek = { anahtar: null, zaman: 0, hataZamani: 0 };
async function metin(url, { timeout = 15000, jsBekle = false } = {}) {
const r = await fetch(url, { headers: { 'User-Agent': UA, Accept: '*/*' }, signal: AbortSignal.timeout(timeout) });
if (!r.ok) throw new Error(`${url.split('/').pop()} → HTTP ${r.status}`);
const t = await r.text();
if (t.length > MAX_BAYT) throw new Error('beklenenden büyük dosya');
// Bilinmeyen yol SPA fallback ile HTML döndürüyor; JS beklerken HTML almak
// parse'ı hatasız ama sonuçsuz bırakır. Kök sayfa zaten HTML — kontrol
// yalnızca JS beklenen isteklere uygulanır.
if (jsBekle && /^\s*<!doctype html/i.test(t)) throw new Error('JS yerine HTML döndü (SPA fallback)');
return t;
}
/**
* Portalın kendi paketinden güncel anahtarı çıkarır.
* Bulamazsa null döner — çağıran taraf mevcut anahtarla devam eder.
*/
export async function anahtarKesfet({ zorla = false } = {}) {
const simdi = Date.now();
if (!zorla) {
if (onbellek.anahtar && simdi - onbellek.zaman < BASARI_TTL) return onbellek.anahtar;
if (simdi - onbellek.hataZamani < HATA_TTL) return null;
}
try {
const kok = await metin(`${BASE}/ekap/search`);
const mainAd = /<script[^>]*src="([^"]*main[^"]*\.js)"/.exec(kok)?.[1];
if (!mainAd) throw new Error('main.js bulunamadı');
const main = await metin(`${BASE}/${mainAd.replace(/^\//, '')}`, { jsBekle: true });
// 2076 numaralı chunk "common" adıyla yayımlanıyor (webpack özel durumu)
const commonHash = /2076\s*:\s*["']([a-f0-9]{8,})["']/.exec(main)?.[1];
if (!commonHash) throw new Error('common chunk hash bulunamadı');
const common = await metin(`${BASE}/common.${commonHash}.js`, { jsBekle: true });
const anahtar = /r8fact\s*[:=]\s*["']([^"']{16,64})["']/.exec(common)?.[1];
if (!anahtar) throw new Error('r8fact bulunamadı');
// AES-256 32 bayt bekler; farklı uzunluk şema değişmiş demektir, körlemesine kullanma.
if (anahtar.length !== 32) throw new Error(`beklenmeyen anahtar uzunluğu: ${anahtar.length}`);
onbellek = { anahtar, zaman: simdi, hataZamani: 0 };
return anahtar;
} catch (e) {
onbellek.hataZamani = simdi;
console.warn('[ekap-key] anahtar keşfedilemedi:', e.message);
return null;
}
}
export function sonKesfedilen() {
return onbellek.anahtar;
}