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:
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user