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;
}
+25 -3
View File
@@ -11,12 +11,21 @@
*/
import crypto from 'node:crypto';
import { anahtarKesfet } from './ekap-key.mjs';
const BASE = 'https://ekapv2.kik.gov.tr';
const DEFAULT_KEY = 'pfS7Xdn3YVkOzs3V79XUc91SD47mQD0g';
// EKAP anahtarı döndürdüğünde portalın kendi paketinden keşfedilen değer.
// Yalnızca kimlik doğrulama hatasından sonra dolar; normalde null kalır.
let kesfedilenAnahtar = null;
function aktifAnahtar() {
return kesfedilenAnahtar || process.env.EKAP_R8FACT_KEY || DEFAULT_KEY;
}
function aesKey() {
return Buffer.from(process.env.EKAP_R8FACT_KEY || DEFAULT_KEY, 'utf8');
return Buffer.from(aktifAnahtar(), 'utf8');
}
function aesB64(plain, iv) {
@@ -51,6 +60,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
export async function ekapPost(path, body, { retries = 2, timeout = 30000 } = {}) {
let lastErr;
let anahtarDenendi = false;
for (let i = 0; i <= retries; i++) {
try {
const res = await fetch(BASE + path, {
@@ -61,8 +71,20 @@ export async function ekapPost(path, body, { retries = 2, timeout = 30000 } = {}
});
const raw = await res.text();
if (!res.ok) {
// 401/1202 = timestamp kayması → yeni başlıkla tekrar dene
if ((res.status === 401 || res.status >= 500) && i < retries) {
// 401/1202 = timestamp kayması → yeni başlıkla tekrar dene.
// Anahtar rotasyonu da buradan geçiyor ve EKAP bunu bazen 401,
// bazen 500 ile bildiriyor (imza çözülemeyince şifre çözme katmanı
// patlıyor) — bu yüzden 5xx de anahtar yenilemeyi tetikliyor.
if ((res.status === 401 || res.status === 403 || res.status >= 500) && i < retries) {
if (!anahtarDenendi) {
anahtarDenendi = true; // istek başına bir kez: gerçek sunucu
// hatasında her denemede portalden üç dosya indirmeyelim
const yeni = await anahtarKesfet();
if (yeni && yeni !== aktifAnahtar()) {
console.warn('[ekap] imzalama anahtarı değişmiş — portalden yeni anahtar alındı');
kesfedilenAnahtar = yeni;
}
}
await sleep(500 * (i + 1));
continue;
}