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 crypto from 'node:crypto';
|
||||||
|
import { anahtarKesfet } from './ekap-key.mjs';
|
||||||
|
|
||||||
const BASE = 'https://ekapv2.kik.gov.tr';
|
const BASE = 'https://ekapv2.kik.gov.tr';
|
||||||
const DEFAULT_KEY = 'pfS7Xdn3YVkOzs3V79XUc91SD47mQD0g';
|
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() {
|
function aesKey() {
|
||||||
return Buffer.from(process.env.EKAP_R8FACT_KEY || DEFAULT_KEY, 'utf8');
|
return Buffer.from(aktifAnahtar(), 'utf8');
|
||||||
}
|
}
|
||||||
|
|
||||||
function aesB64(plain, iv) {
|
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 } = {}) {
|
export async function ekapPost(path, body, { retries = 2, timeout = 30000 } = {}) {
|
||||||
let lastErr;
|
let lastErr;
|
||||||
|
let anahtarDenendi = false;
|
||||||
for (let i = 0; i <= retries; i++) {
|
for (let i = 0; i <= retries; i++) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(BASE + path, {
|
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();
|
const raw = await res.text();
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
// 401/1202 = timestamp kayması → yeni başlıkla tekrar dene
|
// 401/1202 = timestamp kayması → yeni başlıkla tekrar dene.
|
||||||
if ((res.status === 401 || res.status >= 500) && i < retries) {
|
// 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));
|
await sleep(500 * (i + 1));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user