Files
ihaleist/server/ekap-key.mjs
T
hikmettanandClaude Opus 5 9c142fa0f8 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>
2026-09-03 20:04:11 +03:00

78 lines
3.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
}