import Foundation /// In-place CENC/CBCS decryptie van gefragmenteerde MP4 (init + media segments). enum CencMp4Decryptor { struct TrackCrypto { var kid: Data var perSampleIvSize: Int var constantIv: Data? var scheme: String // cenc | cbcs | cbc1 | cens var cryptByteBlock: Int var skipByteBlock: Int } struct SessionState { var byTrackId: [UInt32: TrackCrypto] = [:] var fallback: TrackCrypto? } /// Diagnose-hook (alleen aanroepen onder de crypto-lock van de proxy). static var diagTag = "" static var diagSink: ((String) -> Void)? private static var diagCounts: [String: Int] = [:] private static func diag(_ message: @autoclosure () -> String) { guard let sink = diagSink else { return } let n = diagCounts[diagTag, default: 0] guard n < 8 else { return } diagCounts[diagTag] = n + 1 sink("decrypt[\(diagTag)] \(message())") } static func resetDiag() { diagCounts.removeAll() } private static func hex(_ d: Data) -> String { d.map { String(format: "%02x", $0) }.joined() } @discardableResult static func process(data: inout Data, keys: ClearKeyKeyMap, state: inout SessionState) -> Bool { guard !data.isEmpty, !keys.isEmpty else { return false } // Nooit crashen op corrupte segmenten — liever ongedecrypt doorgeven dan app-kill. var touched = false if containsBox(data, type: "moov") { harvestAndClearInit(&data, keys: keys, state: &state) touched = true } if containsBox(data, type: "moof") { decryptFragments(&data, keys: keys, state: state) touched = true } return touched } // MARK: - Box helpers private struct Box { let type: String let start: Int let headerSize: Int let size: Int var payloadStart: Int { start + headerSize } var payloadEnd: Int { start + size } var payloadLength: Int { size - headerSize } } private static func containsBox(_ data: Data, type: String) -> Bool { findBoxes(data, type: type, recursive: true).isEmpty == false } private static func findBoxes(_ data: Data, type: String, recursive: Bool) -> [Box] { var out: [Box] = [] walk(data, from: 0, to: data.count, recursive: recursive) { box in if box.type == type { out.append(box) } return true } return out } private static func walk( _ data: Data, from: Int, to: Int, recursive: Bool, visit: (Box) -> Bool ) { var offset = from let containers: Set = [ "moov", "trak", "mdia", "minf", "stbl", "moof", "traf", "mvex", "edts", "sinf", "schi", "dinf", "udta", "meta", // Sample-entries bevatten sinf/avcC; stsd is FullBox met entry_count. "stsd", "dref", "encv", "enca", "avc1", "avc3", "hev1", "hvc1", "dvh1", "mp4a", ] while offset + 8 <= to { guard let box = readBox(data, at: offset, limit: to) else { break } let cont = visit(box) if !cont { return } if recursive, containers.contains(box.type), box.payloadLength > 0 { let childFrom = walkChildrenStart(box) if childFrom < box.payloadEnd { walk(data, from: childFrom, to: box.payloadEnd, recursive: true, visit: visit) } } offset = box.payloadEnd if box.size <= 0 { break } } } /// Skip FullBox / SampleEntry vaste headers zodat child-boxes (sinf, tenc, avcC) bereikbaar zijn. private static func walkChildrenStart(_ box: Box) -> Int { switch box.type { case "stsd", "dref": // FullBox (4) + entry_count (4) return box.payloadStart + 8 case "meta": return box.payloadStart + 4 case "encv", "avc1", "avc3", "hev1", "hvc1", "dvh1": // VisualSampleEntry: boxhdr(8) + SampleEntry(8) + visual(70) = 86 return box.start + 86 case "enca", "mp4a": // AudioSampleEntry: boxhdr(8) + SampleEntry(8) + audio(20) = 36 return box.start + 36 default: return box.payloadStart } } private static func readBox(_ data: Data, at offset: Int, limit: Int) -> Box? { guard offset + 8 <= limit else { return nil } var size = Int(readU32(data, offset)) let type = readFourCC(data, offset + 4) var header = 8 if size == 1 { guard offset + 16 <= limit else { return nil } let large = readU64(data, offset + 8) size = Int(large) header = 16 } else if size == 0 { size = limit - offset } guard size >= header, offset + size <= limit else { return nil } return Box(type: type, start: offset, headerSize: header, size: size) } private static func readU16(_ data: Data, _ o: Int) -> UInt16 { guard o >= 0, o + 2 <= data.count else { return 0 } return UInt16(data[o]) << 8 | UInt16(data[o + 1]) } private static func readU32(_ data: Data, _ o: Int) -> UInt32 { guard o >= 0, o + 4 <= data.count else { return 0 } return UInt32(data[o]) << 24 | UInt32(data[o + 1]) << 16 | UInt32(data[o + 2]) << 8 | UInt32(data[o + 3]) } private static func readU64(_ data: Data, _ o: Int) -> UInt64 { guard o >= 0, o + 8 <= data.count else { return 0 } return (UInt64(readU32(data, o)) << 32) | UInt64(readU32(data, o + 4)) } private static func readFourCC(_ data: Data, _ o: Int) -> String { guard o >= 0, o + 4 <= data.count else { return "????" } return String(bytes: data[o..<(o + 4)], encoding: .ascii) ?? "????" } private static func writeFourCC(_ data: inout Data, _ o: Int, _ s: String) { let chars = Array(s.utf8) guard chars.count == 4, o + 4 <= data.count else { return } for i in 0..<4 { data[o + i] = chars[i] } } // MARK: - Init segment private static func harvestAndClearInit(_ data: inout Data, keys: ClearKeyKeyMap, state: inout SessionState) { // Elke init is de bron van waarheid voor deze lane (CMAF track_ID is vaak 1 voor A én V). state.byTrackId.removeAll(keepingCapacity: true) state.fallback = nil // Track id from tkhd, crypto from sinf/tenc/frma/schm inside same trak. for trak in findBoxes(data, type: "trak", recursive: true) { var trackId: UInt32? walk(data, from: trak.payloadStart, to: trak.payloadEnd, recursive: true) { box in if box.type == "tkhd", box.payloadLength >= 8 { let version = Int(data[box.payloadStart]) let idOff = box.payloadStart + (version == 1 ? 20 : 12) if idOff + 4 <= box.payloadEnd { trackId = readU32(data, idOff) } } return true } var crypto: TrackCrypto? var originalFormat: String? walk(data, from: trak.payloadStart, to: trak.payloadEnd, recursive: true) { box in if box.type == "frma", box.payloadLength >= 4 { originalFormat = readFourCC(data, box.payloadStart) } if box.type == "schm", box.payloadLength >= 8 { // version/flags (4) + scheme_type (4) let scheme = readFourCC(data, box.payloadStart + 4) if crypto == nil { crypto = TrackCrypto( kid: Data(count: 16), perSampleIvSize: 8, constantIv: nil, scheme: scheme, cryptByteBlock: 0, skipByteBlock: 0 ) } else { crypto?.scheme = scheme } } if box.type == "tenc", box.payloadLength >= 20 { let version = Int(data[box.payloadStart]) let base = box.payloadStart + 4 // na version+flags var o = base o += 1 // reserved var cryptBlocks = 0 var skipBlocks = 0 if version >= 1 { let pattern = data[o] cryptBlocks = Int((pattern >> 4) & 0x0F) skipBlocks = Int(pattern & 0x0F) } o += 1 // reserved (v0) of pattern (v1) let isProtectedIndex = o let isProtected = Int(data[o]); o += 1 let ivSize = Int(data[o]); o += 1 let kid = data.subdata(in: o..<(o + 16)); o += 16 _ = isProtected var constantIv: Data? if ivSize == 0, o < box.payloadEnd { let constSize = Int(data[o]); o += 1 if constSize > 0, o + constSize <= box.payloadEnd { constantIv = data.subdata(in: o..<(o + constSize)) } } crypto = TrackCrypto( kid: kid, perSampleIvSize: ivSize, constantIv: constantIv, scheme: crypto?.scheme ?? "cenc", cryptByteBlock: cryptBlocks, skipByteBlock: skipBlocks ) data[isProtectedIndex] = 0 } return true } // Replace encv/enca with original format (same 4 bytes → no size rewrite). // Mark sinf as 'free' zodat VLC geen DRM-sample-entry meer ziet (anders: meteen Gestopt). if let originalFormat { for stsd in findBoxes(data, type: "stsd", recursive: true) { guard stsd.start >= trak.start, stsd.payloadEnd <= trak.payloadEnd else { continue } var o = stsd.payloadStart + 8 // FullBox (4) + entry_count (4) while o + 8 <= stsd.payloadEnd { guard let entry = readBox(data, at: o, limit: stsd.payloadEnd) else { break } if entry.type == "encv" || entry.type == "enca" { writeFourCC(&data, entry.start + 4, originalFormat) } o = entry.payloadEnd } } } walk(data, from: trak.payloadStart, to: trak.payloadEnd, recursive: true) { box in if box.type == "sinf" { writeFourCC(&data, box.start + 4, "free") } return true } if let crypto { diag("init track=\(trackId ?? 0) fmt=\(originalFormat ?? "-") scheme=\(crypto.scheme) pat=\(crypto.cryptByteBlock):\(crypto.skipByteBlock) iv=\(crypto.perSampleIvSize) civ=\(crypto.constantIv?.count ?? 0) kid=\(hex(crypto.kid)) exact=\(keys.hasExactKey(forKid: crypto.kid)) keys=\(keys.uniqueKeyCount)") if let trackId { state.byTrackId[trackId] = crypto } // Fallback: prefer video-achtige crypto (cbcs / pattern) boven pure audio-cenc. let looksVideo = crypto.scheme == "cbcs" || crypto.scheme == "cbc1" || crypto.cryptByteBlock > 0 || crypto.constantIv != nil if state.fallback == nil || looksVideo { state.fallback = crypto } _ = keys.key(forKid: crypto.kid) } else { diag("init track=\(trackId ?? 0) fmt=\(originalFormat ?? "-") clear (geen tenc)") } } } // MARK: - Media fragments private static func decryptFragments(_ data: inout Data, keys: ClearKeyKeyMap, state: SessionState) { // Pair each moof with the following mdat. var offset = 0 while offset + 8 <= data.count { guard let box = readBox(data, at: offset, limit: data.count) else { break } if box.type == "moof" { let moof = box var mdat: Box? var scan = moof.payloadEnd while scan + 8 <= data.count { guard let next = readBox(data, at: scan, limit: data.count) else { break } if next.type == "mdat" { mdat = next break } if next.type == "moof" { break } scan = next.payloadEnd } if let mdat { decryptMoofMdat(moof: moof, mdat: mdat, data: &data, keys: keys, state: state) } } offset = box.payloadEnd } } private static func decryptMoofMdat( moof: Box, mdat: Box, data: inout Data, keys: ClearKeyKeyMap, state: SessionState ) { let trafs = findBoxesInRange(data, type: "traf", from: moof.payloadStart, to: moof.payloadEnd) var mdatCursor = mdat.payloadStart for traf in trafs { var trackId: UInt32 = 0 var defaultSampleSize: UInt32 = 0 var baseDataOffset: Int64 = Int64(mdat.payloadStart) var dataOffsetPresent = false var sampleSizes: [Int] = [] var sencSamples: [SencSample] = [] var perSampleIvSize = 8 var tfhdFlagsSeen: UInt32 = 0 var trunFlagsSeen: UInt32 = 0 if let known = state.byTrackId.values.first, state.byTrackId.count == 1 { perSampleIvSize = known.perSampleIvSize } // tfhd for tfhd in findBoxesInRange(data, type: "tfhd", from: traf.payloadStart, to: traf.payloadEnd) { guard tfhd.payloadLength >= 8 else { continue } let versionFlags = readU32(data, tfhd.payloadStart) let flags = versionFlags & 0x00FF_FFFF var o = tfhd.payloadStart + 4 trackId = readU32(data, o); o += 4 if let crypto = state.byTrackId[trackId] { perSampleIvSize = crypto.perSampleIvSize } if flags & 0x000001 != 0 { // base-data-offset guard o + 8 <= tfhd.payloadEnd else { break } baseDataOffset = Int64(readU64(data, o)); o += 8 dataOffsetPresent = true } if flags & 0x000002 != 0 { // sample-description-index guard o + 4 <= tfhd.payloadEnd else { break } o += 4 } if flags & 0x000008 != 0 { // default-sample-duration guard o + 4 <= tfhd.payloadEnd else { break } o += 4 } if flags & 0x000010 != 0 { // default-sample-size guard o + 4 <= tfhd.payloadEnd else { break } defaultSampleSize = readU32(data, o); o += 4 } tfhdFlagsSeen = flags _ = versionFlags } // trun for trun in findBoxesInRange(data, type: "trun", from: traf.payloadStart, to: traf.payloadEnd) { guard trun.payloadLength >= 8 else { continue } let versionFlags = readU32(data, trun.payloadStart) let version = Int(versionFlags >> 24) let flags = versionFlags & 0x00FF_FFFF trunFlagsSeen = flags var o = trun.payloadStart + 4 let sampleCount = Int(readU32(data, o)); o += 4 guard sampleCount >= 0, sampleCount < 100_000 else { continue } var dataOffset: Int32 = 0 if flags & 0x000001 != 0 { guard o + 4 <= trun.payloadEnd else { break } dataOffset = Int32(bitPattern: readU32(data, o)); o += 4 dataOffsetPresent = true } if flags & 0x000004 != 0 { guard o + 4 <= trun.payloadEnd else { break } o += 4 } sampleSizes.removeAll(keepingCapacity: true) for _ in 0..= 8 guard !sampleSizes.isEmpty, !sencSamples.isEmpty || canUseConstantIv else { diag("frag track=\(trackId) skip: samples=\(sampleSizes.count) senc=\(sencSamples.count) tfhd=0x\(String(tfhdFlagsSeen, radix: 16)) trun=0x\(String(trunFlagsSeen, radix: 16))") // Advance cursor even if we cannot decrypt. for sz in sampleSizes { mdatCursor += sz } continue } // Exacte KID-match, anders primaryKey als er maar één key is. let key: Data? = { guard let crypto else { return keys.primaryKey } return keys.key(forKid: crypto.kid) }() guard let key else { diag("frag track=\(trackId) skip: geen key (kid=\(crypto.map { hex($0.kid) } ?? "-") keys=\(keys.uniqueKeyCount))") for sz in sampleSizes { mdatCursor += sz } continue } let scheme = (crypto?.scheme ?? "cenc").lowercased() let cryptBlocks = crypto?.cryptByteBlock ?? 0 let skipBlocks = crypto?.skipByteBlock ?? 0 var decryptedCount = 0 for i in 0.. 0, mdatCursor + size <= data.count else { break } let sampleRange = mdatCursor..<(mdatCursor + size) var sample = data.subdata(in: sampleRange) let senc = i < sencSamples.count ? sencSamples[i] : nil let iv = senc?.iv ?? constantIv if let iv { if let decrypted = decryptSample( sample, key: key, iv: iv, subsamples: senc?.subsamples, scheme: scheme, cryptByteBlock: cryptBlocks, skipByteBlock: skipBlocks ) { sample = decrypted data.replaceSubrange(sampleRange, with: sample) decryptedCount += 1 } } mdatCursor += size } diag("frag track=\(trackId) crypto=\(crypto != nil) scheme=\(scheme) pat=\(cryptBlocks):\(skipBlocks) samples=\(sampleSizes.count) senc=\(sencSamples.count) iv=\(sencSamples.first?.iv.count ?? 0) subs=\(sencSamples.first?.subsamples?.count ?? -1) size0=\(sampleSizes.first ?? 0) dec=\(decryptedCount) tfhd=0x\(String(tfhdFlagsSeen, radix: 16)) trun=0x\(String(trunFlagsSeen, radix: 16)) mdat=\(mdat.payloadLength)") // AVPlayer/VLC mogen geen tweede decrypt-poging doen op clear samples. if decryptedCount > 0 { for senc in findBoxesInRange(data, type: "senc", from: traf.payloadStart, to: traf.payloadEnd) { writeFourCC(&data, senc.start + 4, "free") } } } } private struct SencSample { var iv: Data var subsamples: [(clear: Int, protected: Int)]? } private static func parseSenc( _ data: Data, box: Box, perSampleIvSize: Int, constantIv: Data? ) -> [SencSample] { let versionFlags = readU32(data, box.payloadStart) let flags = versionFlags & 0x00FF_FFFF var o = box.payloadStart + 4 let sampleCount = Int(readU32(data, o)); o += 4 var out: [SencSample] = [] out.reserveCapacity(sampleCount) let useSubsamples = (flags & 0x2) != 0 for _ in 0.. 0 { let n = min(perSampleIvSize, 16) guard o + n <= box.payloadEnd else { break } iv.replaceSubrange(0.. [SencSample] { let saizBoxes = findBoxesInRange(data, type: "saiz", from: traf.payloadStart, to: traf.payloadEnd) let saioBoxes = findBoxesInRange(data, type: "saio", from: traf.payloadStart, to: traf.payloadEnd) guard let saiz = saizBoxes.first, let saio = saioBoxes.first else { return [] } let saizFlags = readU32(data, saiz.payloadStart) & 0x00FF_FFFF var o = saiz.payloadStart + 4 if saizFlags & 0x1 != 0 { guard o + 8 <= saiz.payloadEnd else { return [] } o += 8 } guard o + 5 <= saiz.payloadEnd else { return [] } let defaultSize = Int(data[o]); o += 1 let count = Int(readU32(data, o)); o += 4 guard count >= 0, count < 100_000 else { return [] } var sizes: [Int] = [] if defaultSize == 0 { guard o + count <= saiz.payloadEnd else { return [] } for _ in 0..> 24) let saioFlags = saioVersionFlags & 0x00FF_FFFF var so = saio.payloadStart + 4 if saioFlags & 0x1 != 0 { so += 8 } let entryCount = Int(readU32(data, so)); so += 4 guard entryCount >= 1 else { return [] } let auxOffset: Int if saioVersion == 0 { auxOffset = Int(readU32(data, so)) } else { auxOffset = Int(readU64(data, so)) } // Offsets are usually relative to moof start (ISO) or absolute file offset. var auxPos = auxOffset if auxPos < moof.start || auxPos > data.count { auxPos = moof.start + auxOffset } guard auxPos >= 0, auxPos < data.count else { return [] } var out: [SencSample] = [] let n = min(sizes.count, sampleCount > 0 ? sampleCount : sizes.count) for i in 0.. 0 { let ivn = min(perSampleIvSize, 16) iv.replaceSubrange(0.. perSampleIvSize { let sc = Int(readU16(data, p)); p += 2 var list: [(clear: Int, protected: Int)] = [] for _ in 0.. Data? { if scheme == "cbcs" || scheme == "cbc1" { return decryptCbcsSample( sample, key: key, iv: iv, subsamples: subsamples, cryptByteBlock: cryptByteBlock, skipByteBlock: skipByteBlock ) } // cenc / cens: AES-CTR. Concatenate protected ranges → one CTR stream → merge. guard let ranges = protectedRanges(sampleCount: sample.count, subsamples: subsamples), !ranges.isEmpty else { return AesCtr.crypt(key: key, iv: iv, data: sample) } var encrypted = Data() for r in ranges { encrypted.append(sample.subdata(in: r)) } guard let plain = AesCtr.crypt(key: key, iv: iv, data: encrypted) else { return nil } var out = sample var read = 0 for r in ranges { let len = r.count out.replaceSubrange(r, with: plain.subdata(in: read..<(read + len))) read += len } return out } private static func decryptCbcsSample( _ sample: Data, key: Data, iv: Data, subsamples: [(clear: Int, protected: Int)]?, cryptByteBlock: Int, skipByteBlock: Int ) -> Data? { // Per subsample: IV reset (Chromium/ISO). Pattern chaint binnen de protected range. var out = sample if let subsamples { var cursor = 0 for sub in subsamples { cursor += sub.clear let end = cursor + sub.protected guard end <= out.count else { break } let slice = out.subdata(in: cursor.. [Range]? { guard sampleCount > 0 else { return nil } guard let subsamples else { return [0..] = [] var cursor = 0 for sub in subsamples { cursor += max(0, sub.clear) let prot = max(0, sub.protected) if prot > 0 { let end = min(cursor + prot, sampleCount) if cursor < end { ranges.append(cursor..= sampleCount { break } } return ranges } private static func findBoxesInRange(_ data: Data, type: String, from: Int, to: Int) -> [Box] { var out: [Box] = [] walk(data, from: from, to: to, recursive: true) { box in if box.type == type { out.append(box) } return true } return out } }