HATA ANALİZİ AES-128 HLS.js v1.4.12 v2 - Kapsamlı Araştırma

HLS.js keyLoadError: "decryptdata unset or changed"

ABR Level Switch Sırasında Key Loading Race Condition - Kaynak Kod Analizi ve Çözüm Araştırması

Basit Anlatım (Herkes İçin)

Sorun Ne?

Müzik dinlerken bazen ses kopuyor veya şarkı başlayamıyor. HLS.js kütüphanesi şifreleme anahtarını (encryption key) yüklemeye çalışırken, aynı anda kalite değiştirmeye de çalışıyor. Bu iki işlem çarpışıyor ve "anahtarımı kaybettim" hatası veriyor.

Neden Oluyor?

Düşünün ki bir kasadaki anahtarı almak için gönderiyor olun. Anahtarı almaya giden kişi yolda iken, siz kasanın kilidini değiştiriyorsunuz. Kişi geri geldiğinde "bu anahtar artık bu kasaya uymuyor" diyor. İşte HLS.js'de de tam olarak bu oluyor: Anahtar indirilirken, kalite değişikliği yapılınca anahtar bilgisi değişiyor ve eski indirme işlemi başarısız oluyor.

Mevcut Durumda Ne Yapıldı?

Şu anda player-core.js'de birçok koruma katmanı var: ABR başlangıçta kilitlenmesi, anahtar ön-yükleme, fatal hata sonrası kaynak yeniden yükleme ve HLS havuz yöneticisi ile kirli instance temizleme. Bunlar sorunu büyük ölçüde azaltıyor ama HLS.js'nin iç yapısındaki sorun devam ediyor.

Bilinen Bir Bug mı?

Sonuç: Resmi bir GitHub Issue YOK, ama bilinen bir tasarım sınırlaması

HLS.js GitHub deposunda (video-dev/hls.js) "after key load, decryptdata unset or changed" hata mesajı ile doğrudan eşleşen bir issue bulunamadı. Ancak bu hata mesajı, HLS.js kaynak kodunda kasıtlı olarak yerleştirilmiş bir güvenlik kontrolüdür.

İlgili GitHub issue'ları:

  • #5244 - "Sometimes play AES-128 stream failed" (v1.3.3) - Benzer belirtiler, çözümsüz
  • #7022 - Custom loader ile decryptdata etkileşimi sorunları
  • #7474 - Multi-key handling mimarisi tartışması (devam ediyor)
  • #1836 - Key-loader error handling (eski, key retry sorunları)

Bu sorun, HLS.js'nin key-loader mimarisindeki yapısal bir sınırlamadır. Hata mesajı, race condition tespiti için bilinçli olarak eklenmiş bir guard clause'dur. Resmi olarak bir "bug" olarak raporlanmamış, çünkü HLS.js geliştiricileri bunu bir güvenlik kontrolü olarak görüyor.

Daha Yeni Versiyonda Düzeltildi mi?

Sonuç: HAYIR - Aynı kod v1.4.12'den v1.6.15'e kadar değişmeden duruyor

HLS.js'nin tüm ana versiyonları karşılaştırıldı:

Versiyon Key Map Adı Referans Kontrolü Bu Hata Var mı?
v1.4.12 keyUriToKeyInfo keyInfo !== this.keyUriToKeyInfo[uri] EVET
v1.5.0 keyUriToKeyInfo keyInfo !== this.keyUriToKeyInfo[uri] EVET
v1.6.0 keyUriToKeyInfo keyInfo !== this.keyUriToKeyInfo[uri] EVET
master (son) keyIdToKeyInfo keyInfo !== this.keyIdToKeyInfo[id] KISMI

Master Branch'teki Değişiklik

Master branch'te keyUriToKeyInfo yerine keyIdToKeyInfo kullanılıyor ve getKeyId() fonksiyonu ile anahtar indeksleme yapılıyor. Bu, aynı URI'yi farklı IV'lerle kullanan level'lar için daha iyi çalışabilir, çünkü key ID daha spesifik bir tanımlayıcı. Ancak temel referans eşitliği kontrolü (===) hala aynı ve aynı race condition tetiklenebilir.

Neden Düzeltilmedi?

HLS.js geliştiricileri bu kontrolü bir güvenlik mekanizması olarak görüyor. Stale (bayatlamış) key verisi ile şifre çözme yapmak veri bozulmasına yol açabilir. Bu nedenle "hatalı key ile devam etmektense hata vermek" yaklaşımını benimsemişler. Sorun HLS.js'de değil, aynı key URI'sini paylaşan multi-level stream'lerin race condition'a açık yapısında.

key-loader.ts Kaynak Kod Analizi

Hata, src/loader/key-loader.ts dosyasındaki loadKeyHTTP() metodunun onSuccess callback'inde oluşuyor.

Adım 1: Key Yükleme Başlatılır

// key-loader.ts - loadKeyHTTP() metodu (v1.4.12) private loadKeyHTTP(keyInfo: KeyLoaderInfo, frag: Fragment): Promise<KeyLoadedData> { const { decryptdata } = keyInfo; // decryptdata referansını al const uri = decryptdata.uri; // key URI'si return new Promise((resolve, reject) => { // ... loader kurulumu ... this.loader.load(context, loaderConfig, { onSuccess: (response, stats, ctx, networkDetails) => { // ---- HATA BURADA OLUŞUYOR ----

Adım 2: Referans Kontrolü (Hatanın Kaynağı)

// onSuccess callback - XHR başarılı dönünce çağrılır onSuccess: (response, stats, context, networkDetails) => { const { frag, keyInfo } = context; // !!!!! SORUNLU KONTROL !!!!! if (!frag.decryptdata || keyInfo !== this.keyUriToKeyInfo[uri]) { return reject( this.createKeyLoadError( frag, ErrorDetails.KEY_LOAD_ERROR, new Error('after key load, decryptdata unset or changed'), networkDetails, ), ); } // Başarılı durumda: key verisini ata keyInfo.decryptdata.key = new Uint8Array(response.data); frag.decryptdata.key = keyInfo.decryptdata.key; resolve({ frag, keyInfo }); }

Hatanın Tetiklenme Akışı

1
Level 0 yüklenir: Fragment A'nın key'i için XHR başlatılır. keyUriToKeyInfo["enc.bin?token=X"] = keyInfo_A olarak kaydedilir.
2
ABR level switch tetiklenir: HLS.js bant genişliğine göre Level 1'e geçmeye karar verir. Level 1'in playlist'i parse edilir.
3
Level 1'in key kaydı map'i overwrite eder: Level 1 de aynı enc.bin?token=X URI'sini kullanıyor (farklı IV ile). Parse sırasında keyUriToKeyInfo["enc.bin?token=X"] = keyInfo_B (yeni nesne!) ile overwrite edilir.
4
XHR tamamlanır, referans uyuşmaz: Adım 1'deki XHR döner. Callback keyInfo_A referansını tutuyor, ama map'te artık keyInfo_B var. keyInfo_A !== keyInfo_B --> HATA!

İkincil Sorun: LevelKey.getDecryptData() Yeni Nesne Oluşturması

// level-key.ts - getDecryptData() metodu getDecryptData(sn: number | 'initSegment'): LevelKey | null { // AES-128 için: IV yoksa segment numarasından üretilir if (method === 'AES-128' && this.uri && !this.iv) { // IV = segment numarasından oluşturulur const iv = createInitializationVector(sn); // !!!!! YENİ NESNE OLUŞTURULUYOR !!!!! const decryptdata = new LevelKey( this.method, this.uri, 'identity', this.keyFormatVersions, iv ); return decryptdata; // this DEĞİL, yeni nesne! } // Diğer durumlar: this döner (aynı referans) return this; }

Neden Önemli: AES-128 ile IV'siz playlist'lerde, her frag.decryptdata erişiminde getDecryptData() yeni bir LevelKey nesnesi oluşturur. Fragment sınıfındaki getter bunu cache'ler (_decryptdata), ama fragment nesnesi değiştirilir veya yeniden oluşturulursa, referans eşitliği bozulabilir.

Fragment.decryptdata Getter (Lazy Initialization)

// fragment.ts - decryptdata getter get decryptdata(): LevelKey | null { const { levelkeys } = this; if (!levelkeys || levelkeys.NONE) { return null; // Şifreleme yok } if (levelkeys.identity) { if (!this._decryptdata) { // İLK erişimde: getDecryptData() çağrılır → yeni nesne oluşur this._decryptdata = levelkeys.identity.getDecryptData(this.sn); } } return this._decryptdata; // Cache'lenmiş referans }

Cache mekanizması: _decryptdata bir kez set edilince aynı referans döner. Ancak fragment nesnesi yeniden oluşturulursa (level switch gibi), _decryptdata = null ile başlar ve getDecryptData() yeni nesne oluşturur. Bu yeni nesne, key-loader'daki map'teki keyInfo'nun decryptdata alanı ile aynı referans olmaz.

Bilinen Çözümler ve Workaround'lar

HLS.js'nin resmi bir fix'i yok. Aşağıdaki yöntemler topluluk ve kendi uygulamamızdan derlendi.

UYGULANDI 1. ABR Startup Lock (En Etkili)

Şarkı başlarken ABR'ı level 0'da kilitle, ilk key yüklenene kadar level switch yapma.

// player-core.js satır ~5058 this.hls.autoLevelCapping = 0; // Level 0'da kilitle this._abrStartupLocked = true; // Lock bayrağı // KEY_LOADED sonrası: this.hls.autoLevelCapping = -1; // ABR serbest this._abrStartupLocked = false;

Etkinlik: ~%90 azalma. Key cache'e girince level switch güvenli, çünkü loadKey() cache'ten okur, yeni XHR açmaz.

UYGULANDI 2. Key Pre-fetch (Browser Cache Isıtma)

Şarkı yüklenmeden önce key URL'sini fetch ederek browser HTTP cache'ini ısıt. HLS.js'nin XHR'ı cache'ten hızlı döner.

// player-core.js satır ~371 async function prefetchHlsKey(streamUrl) { var keyUrl = constructKeyUrl(streamUrl); var resp = await fetch(keyUrl); if (resp.ok) { var buf = await resp.arrayBuffer(); if (buf.byteLength === 16) { _hlsKeyCache[keyUrl] = buf; } } }

Key XHR ~50-100ms'de döner (cache'ten). Race condition penceresi küçülür ama tamamen kapanmaz.

UYGULANDI 3. Fatal keyLoadError Recovery (loadSource + startLoad)

Hata oluşursa, manifest'i yeniden yükle. loadSource() tüm decryptdata state'ini sıfırlar.

// player-core.js satır ~5341 if (data.details === 'keyLoadError' && !self._keyStartLoadRecovery) { self._keyStartLoadRecovery = true; self.hls.autoLevelCapping = 0; // ABR tekrar kilitle self._abrStartupLocked = true; setTimeout(function() { self.hls.loadSource(self._lastHlsUrl); // State sıfırla self.hls.startLoad(); // Yeniden başla }, 100); }

UYGULANDI 4. HLS Pool: Her Zaman Destroy + Fresh Instance

HLS instance'ları asla reuse etme. Her release'de destroy et ve yeni instance oluştur. Eski instance'ın stale decryptdata'sı temizlenir.

// player-core.js satır ~595-606 // Pool reuse → stale decryptdata → "decryptdata unset or changed" // Artık her release'de destroy edip fresh instance oluşturuyoruz try { rawHls.stopLoad(); } catch (e) {} try { rawHls.detachMedia(); } catch (e) {} try { rawHls.removeAllListeners(); } catch (e) {} try { rawHls.destroy(); } catch (e) {} // Fresh instance const fresh = new Hls(config);

UYGULANDI 5. startLevel: 0 (Auto Level Devre Dışı)

startLevel: -1 (auto) kullanılırsa, HLS.js tüm level'ları paralel yükler ve key XHR'lar abort edilerek penalty box'a düşer. startLevel: 0 ile tek key XHR garantilenir.

UYGULANMADI 6. HLS.js Kaynak Kod Patch'i (Potansiyel)

key-loader.ts'deki referans kontrolünü URI bazlı eşitlik kontrolüne değiştirmek. Bu, referans yerine anahtar URI'sini karşılaştırır.

// MEVCUT (sorunlu): if (!frag.decryptdata || keyInfo !== this.keyUriToKeyInfo[uri]) { // ÖNERİLEN PATCH: if (!frag.decryptdata || !this.keyUriToKeyInfo[uri]) { // veya URI bazlı kontrol: if (!frag.decryptdata || keyInfo.decryptdata.uri !== this.keyUriToKeyInfo[uri]?.decryptdata?.uri) {

Risk: Bu patch, stale key verisiyle şifre çözmeye izin verebilir. AES-128'de yanlış key ile çözme = bozuk ses verisi. Dikkatli test gerekir. Önerilen yaklaşım: Referans kontrolünü kaldırmak yerine, onSuccess'te map'i güncellemek veya key verisini URI bazlı cache'lemek.

Özet ve Öneriler

Sorunun Özü

  • HLS.js key-loader referans eşitliği (===) kontrolü kullanıyor
  • ABR level switch sırasında keyUriToKeyInfo map'i overwrite ediliyor
  • AES-128 IV'siz stream'lerde getDecryptData() her seferinde yeni nesne oluşturuyor
  • Bu sorun v1.4.12'den v1.6.15'e kadar tüm versiyonlarda mevcut
  • HLS.js geliştiricileri bunu bug değil, güvenlik kontrolü olarak görüyor

Mevcut Korumalar

  • ABR startup lock (level 0) -- key cache'e girene kadar
  • Key pre-fetch -- browser cache ısıtma
  • Fatal recovery -- loadSource() ile state sıfırlama
  • HLS Pool -- destroy + fresh instance
  • startLevel: 0 -- paralel key XHR'ları önleme
  • Non-fatal keyLoadError sessiz geçme

İleriye Dönük Seçenekler

A.
Mevcut workaround'larla devam et: Şu anki korumalar çoğu senaryoyu kapsıyor. ABR lock + pre-fetch + recovery kombinasyonu etkili.
B.
HLS.js v1.4.12'yi özel build ile patch'le: hls.min.js dosyasında onSuccess referans kontrolünü gevşet. Risk: Stale key ile bozuk ses.
C.
Server tarafında her level için benzersiz key URI kullan: Level 0 ve Level 1 farklı key URL'leri kullanırsa, map overwrite olmaz. En temiz çözüm ama server değişikliği gerektirir.
D.
HLS.js GitHub'a PR aç: keyUriToKeyInfo map'inin overwrite yerine merge davranışı göstermesini öner. Topluluk desteği ile kalıcı çözüm.

Kaynak Dosyalar ve Referanslar

Proje public/themes/muzibu/js/player/core/player-core.js
Proje public/assets/libs/hls.js@1.4.12/hls.min.js