M3u8 文件解析和 TS 文件加解密,不知道对不对
資深大佬 : yuyujulin 7
package com.example.demo; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.bouncycastle.util.encoders.Hex; import org.junit.jupiter.api.Test; import javax.crypto.BadPaddingException; import javax.crypto.Cipher; import javax.crypto.IllegalBlockSizeException; import javax.crypto.NoSuchPaddingException; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; import java.io.*; import java.nio.charset.StandardCharsets; import java.security.InvalidAlgorithmParameterException; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import java.security.Security; /** * TS 文件加解密。 包含如下两套加解密方式: * 1. AES/CBC/PKCS7Padding 标准 Java 加解密方式 * 2. AES/CBC/NoPadding 加手动 PKCS7Padding 方式。当前 Stream 采用这种方式。 * <p> * AES-CBC-128 加密 */ public class MediaFileCryptoUtils { // 算法名称 private static final String KEY_ALG = "AES"; /** * 加解密算法 /模式 /填充方式。PKCS7Padding */ private static final String AES_CBC_PKCS7PADDING = "AES/CBC/PKCS7Padding"; /** * 加解密算法 /模式 /填充方式。 * 这里虽然是 NoPadding,但实际最后一个数据块会手动做 PKCS7Padding */ private static final String AES_CBC_NOPADDING = "AES/CBC/NoPadding"; /** * AES 加密数据块分组长度必须为 128 比特( bit 位), * 密钥长度可以是 128 比特、192 比特、256 比特中的任意一个(如果数据块不足密钥长度时,会补齐)。 */ private static final long CIPHER_BLOCK_SIZE = 16; // 每次读取的缓冲区长度,必须为 CIPHER_BLOCK_SIZE 的倍数 private static final int BUFFER_SIZE = 1024; // 加密后的 ts 文件块大小 private static final int TS_BLOCK_SIZE = 188; static { Security.addProvider(new BouncyCastleProvider()); } private static Cipher getCipher(byte[] keyBytes, byte[] ivBytes, String transformation, int encryptMode) { try { Cipher cipher = Cipher.getInstance(transformation); cipher.init(encryptMode, new SecretKeySpec(keyBytes, KEY_ALG), new IvParameterSpec(ivBytes)); return cipher; } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidAlgorithmParameterException | InvalidKeyException e) { throw new RuntimeException("Error occurred while getting cipher", e); } } /** * 用给定的 key 和 iv 加密指定 TS 文件并将结果写入到指定的输出流 * * @param keyString 秘钥字符串,例如 "362ed0938ef220d8" * @param ivHexString 初始向量的十六进制字符串,前面有 0x 开头,例如 "0x04401234f48591766c1a3bc51ab173f0" * @param sourceTS 源 TS 文件路径 * @param os 要输出到的流 */ public static void encryptTS(String keyString, String ivHexString, String sourceTS, OutputStream os) { byte[] keyBytes = keyString.getBytes(StandardCharsets.UTF_8); byte[] ivBytes = Hex.decode(ivHexString.substring(2)); encryptTS(keyBytes, ivBytes, sourceTS, os); } public static void encryptTsWithManualPadding(String keyString, String ivHexString, String sourceTS, OutputStream os) { byte[] keyBytes = keyString.getBytes(StandardCharsets.UTF_8); byte[] ivBytes = Hex.decode(ivHexString.substring(2)); encryptTsWithManualPadding(keyBytes, ivBytes, sourceTS, os); } /** * 用给定的 key 和 iv 加密指定 TS 文件并将结果写入到指定的输出流。 * <p> * AES-CBC 对文件加密的标准 Java 写法。 * * @param keyBytes 秘钥 * @param ivBytes 初始向量 * @param sourceTS 源 TS 文件路径 * @param os 输出流 */ public static void encryptTS(byte[] keyBytes, byte[] ivBytes, String sourceTS, OutputStream os) { // 初始化 cipher, 同一个文件要用一个 Cipher Cipher cipher = getCipher(keyBytes, ivBytes, AES_CBC_PKCS7PADDING, Cipher.ENCRYPT_MODE); File plainFile = new File(sourceTS); try (FileInputStream fis = new FileInputStream(plainFile)) { byte[] buffer = new byte[BUFFER_SIZE]; int length = -1; int count = 0; while ((length = fis.read(buffer)) != -1) { System.out.println("count: " + count++ + ", length: " + length); byte[] encryptedData; // 可读大小为 0,表示当前已读到的数据是最后一块数据 if (fis.available() == 0) { encryptedData = cipher.doFinal(buffer, 0, length); } else { encryptedData = cipher.update(buffer, 0, length); } os.write(encryptedData); } } catch (IOException | BadPaddingException | IllegalBlockSizeException e) { throw new RuntimeException("Error occurred while encrypting ts", e); } } /** * 用给定的 key 和 iv 加密指定 TS 文件并将结果写入到指定的输出流。 * <p> * Stream 里面 TS 加密的 Java 实现,所有数据块采用 AES_CBC_NOPADDING,最后一个数据块需要手动加上 PKCS7Padding 。 * * @param keyBytes 秘钥 * @param ivBytes 初始向量 * @param sourceTS 源 TS 文件路径 * @param os 输出流 */ public static void encryptTsWithManualPadding(byte[] keyBytes, byte[] ivBytes, String sourceTS, OutputStream os) { // 初始化 cipher, 同一个文件要用一个 Cipher Cipher cipher = getCipher(keyBytes, ivBytes, AES_CBC_NOPADDING, Cipher.ENCRYPT_MODE); File plainFile = new File(sourceTS); try (FileInputStream fis = new FileInputStream(plainFile)) { long totalLength = plainFile.length(); int paddingLength = (int) (CIPHER_BLOCK_SIZE - totalLength % CIPHER_BLOCK_SIZE); byte[] buffer = new byte[BUFFER_SIZE]; int length = -1; while ((length = fis.read(buffer)) != -1) { byte[] plainData = buffer; // 可读大小为 0,表示当前已读到的数据是最后一块数据, 且需要 padding if (fis.available() == 0 && paddingLength != 0) { plainData = new byte[length + paddingLength]; System.arraycopy(buffer, 0, plainData, 0, length); // PCKS7 填充,在填充字节上都填相同的数据,比如数据缺少 4 字节,所以所有字节上都填 4 for (int i = length; i < plainData.length; i++) { plainData[i] = (byte) paddingLength; } } /** *这里不要使用 cipher.doFinal 因为 CBC 是循环加密,要把上一个加密快的结果作为下一次加密的 iv 。 * 即使是最后一个数据块也不需要使用 cipher.doFinal,因为上面针对最后一个数据块手动进行了 PKCS7 填充 */ byte[] encryptedData = cipher.update(plainData); os.write(encryptedData); } } catch (IOException e) { throw new RuntimeException("Error occurred while encrypting ts with manual padding", e); } } /** * 用给定的 key 和 iv 解密指定 TS 文件并将结果写入到指定的输出流 * * @param keyString 秘钥字符串,例如 "362ed0938ef220d8" * @param ivHexString 初始向量的十六进制字符串,前面有 0x 开头,例如 "0x04401234f48591766c1a3bc51ab173f0" * @param sourceTS 源 TS 文件路径 * @param os 要输出到的流 */ public static void decryptTS(String keyString, String ivHexString, String sourceTS, OutputStream os) { byte[] keyBytes = keyString.getBytes(StandardCharsets.UTF_8); byte[] ivBytes = Hex.decode(ivHexString.substring(2)); decryptTS(keyBytes, ivBytes, sourceTS, os); } public static void decryptTsWithManualPadding(String keyString, String ivHexString, String sourceTS, OutputStream os) { byte[] keyBytes = keyString.getBytes(StandardCharsets.UTF_8); byte[] ivBytes = Hex.decode(ivHexString.substring(2)); decryptTsWithManualPadding(keyBytes, ivBytes, sourceTS, os); } /** * 用给定的 key 和 iv 解密指定 TS 文件并将结果写入到指定的输出流。 * <p> * AES-CBC 对文件解密的标准 Java 写法。 * * @param keyBytes 秘钥 * @param ivBytes 初始向量 * @param sourceTS 源 TS 文件路径 * @param os 输出流 */ public static void decryptTS(byte[] keyBytes, byte[] ivBytes, String sourceTS, OutputStream os) { // 初始化 cipher, 同一个文件要用一个 Cipher Cipher cipher = getCipher(keyBytes, ivBytes, AES_CBC_PKCS7PADDING, Cipher.DECRYPT_MODE); File encryptedFile = new File(sourceTS); try (FileInputStream fis = new FileInputStream(encryptedFile)) { byte[] buffer = new byte[BUFFER_SIZE]; int length; while ((length = fis.read(buffer)) != -1) { byte[] plainData; if (fis.available() == 0) { plainData = cipher.doFinal(buffer, 0, length); } else { plainData = cipher.update(buffer, 0, length); } os.write(plainData); } } catch (IOException | BadPaddingException | IllegalBlockSizeException e) { throw new RuntimeException("Error occurred while decrypting ts", e); } } /** * 用给定的 key 和 iv 解密指定 TS 文件并将结果写入到指定的输出流。 * <p> * Stream 里面 TS 解密的 Java 实现,所有数据块采用 AES_CBC_NOPADDING,最后一个数据块需要手动去除 padding 。 * * @param keyBytes 秘钥 * @param ivBytes 初始向量 * @param sourceTS 源 TS 文件路径 * @param os 输出流 */ public static void decryptTsWithManualPadding(byte[] keyBytes, byte[] ivBytes, String sourceTS, OutputStream os) { // 初始化 cipher, 同一个文件要用一个 Cipher Cipher cipher = getCipher(keyBytes, ivBytes, AES_CBC_NOPADDING, Cipher.DECRYPT_MODE); File encryptedFile = new File(sourceTS); try (FileInputStream fis = new FileInputStream(encryptedFile)) { byte[] buffer = new byte[BUFFER_SIZE]; int totalLength = fis.available(); int length; while ((length = fis.read(buffer)) != -1) { byte[] plainData = cipher.update(buffer); int plainDataLength = plainData.length; // 默认为解密后的数据长度 if (fis.available() == 0) { // 最后一个解密出来的数据数据块,要去掉 Padding 的数据 // 计算 padding 长度 int paddingLength = totalLength % TS_BLOCK_SIZE; // 去掉无用的 padding plainDataLength = length - paddingLength; } os.write(plainData, 0, plainDataLength); } } catch (IOException e) { throw new RuntimeException("Error occurred while decrypting ts with manual padding", e); } } @Test public void testEncryptFile() { try (FileOutputStream fos = new FileOutputStream(new File("D:\196.ets"))) { encryptTS("7db4fd4359bb25b0", "0xb70cbefa3168efd2d0984abc8181ecff", "D:\196.ts", fos); } catch (IOException e) { e.printStackTrace(); } } @Test public void testDecryptFile() { try (FileOutputStream fos = new FileOutputStream(new File("D:\9.ts"))) { decryptTS("7db4fd4359bb25b0", "0xb70cbefa3168efd2d0984abc8181ecff", "D:\record-crypt\06987ff1-0357-45b1-a6b8-f062e989c82d\videoHD\9.ts", fos); } catch (IOException e) { e.printStackTrace(); } } }
package com.example.demo; import org.junit.jupiter.api.Test; import org.springframework.util.CollectionUtils; import java.io.BufferedReader; import java.io.File; import java.io.FileReader; import java.io.IOException; import java.util.ArrayList; import java.util.LinkedHashSet; import java.util.List; import java.util.Set; import java.util.stream.Collectors; public class M3u8Parser { /** * m3u8 文件头指令:m3u8 文件头。必须在文件第一行。 */ private static final String DIRECTIVE_HEADER = "#EXTM3U"; /** * 码流信息指令:带宽、分辨率,解码器等键值对信息。后一行跟对应码流的 m3u8 文件位置。 */ private static final String DIRECTIVE_STREAM_INF = "#EXT-X-STREAM-INF"; /** * 音频,视频轨道信息指令:时长(秒),标题,其他额外信息(如 logo )以键值对显示。后一行跟对应 ts 的文件位置 */ private static final String DIRECTIVE_TRACK_INF = "#EXTINF"; /** * 列表终止标识指令 */ private static final String DIRECTIVE_ENDLIST = "#EXT-X-ENDLIST"; /** * m3u8 文件包含的最小行数 */ private static final int M3U8_MIN_LINES = 2; public List<String> getAllTsPaths(String indexM3u8) { File indexM3u8File = new File(indexM3u8); if (!indexM3u8File.exists()) { throw new IllegalArgumentException("File not found"); } if (!indexM3u8File.isFile()) { throw new IllegalArgumentException(indexM3u8File + " is not a file"); } String basePath = indexM3u8File.getParentFile().getAbsolutePath(); Set<String> tsSet = parseIndexM3u8(basePath, indexM3u8File); if (CollectionUtils.isEmpty(tsSet)) { throw new IllegalArgumentException("No TS in specified m3u8 file"); } return tsSet.stream().map(tsName -> basePath + File.separator + tsName).collect(Collectors.toList()); } private Set<String> parseIndexM3u8(String basePath, File indexM3u8File) { // index m3u8 文件比较小,一次性读完 List<String> indexM3u8Lines = readAllLines(indexM3u8File); validateM3u8(indexM3u8Lines); for (int i = 1; i < indexM3u8Lines.size(); i++) { String line = indexM3u8Lines.get(i); if (line.startsWith(DIRECTIVE_STREAM_INF)) { // 遇到第一个码流信息,取码流之后的一行就是子 m3u8 文件的位置,当前第一个码流信息就够了 String subM3u8 = basePath + File.separator + indexM3u8Lines.get(i + 1); return parseSubM3u8(subM3u8); } } throw new IllegalArgumentException("Not a valid m3u8 file: no ts info"); } private Set<String> parseSubM3u8(String subM3u8) { // sub m3u8 文件可能会比较大,每读一行就解析一行 try (FileReader fr = new FileReader(new File(subM3u8)); BufferedReader bf = new BufferedReader(fr)) { Set<String> tracks = new LinkedHashSet<>(); String line; while ((line = bf.readLine()) != null) { if (line.startsWith(DIRECTIVE_TRACK_INF)) { // 当前行是轨道信息,就再读一行 line = bf.readLine(); if (line != null) { tracks.add(line); } } if (line.startsWith(DIRECTIVE_ENDLIST)) { break; } } return tracks; } catch (IOException e) { throw new IllegalArgumentException("Error occurred while parsing sub m3u8 file", e); } } private void validateM3u8(List<String> indexM3u8Lines) { if (indexM3u8Lines.size() < M3U8_MIN_LINES) { throw new IllegalArgumentException("Invalid m3u8 file: insufficient lines"); } if (!DIRECTIVE_HEADER.equals(indexM3u8Lines.get(0))) { throw new IllegalArgumentException("Invalid m3u8 file: invalid m3u8 header"); } } public List<String> readAllLines(File file) { List<String> lines = new ArrayList<>(); try (FileReader fr = new FileReader(file); BufferedReader bf = new BufferedReader(fr)) { String line; while ((line = bf.readLine()) != null) { lines.add(line); } return lines; } catch (IOException e) { throw new RuntimeException("Error occurred while reading file", e); } } @Test public void testM3u8Parser() { M3u8Parser m3u8Parser = new M3u8Parser(); m3u8Parser.getAllTsPaths("D:\record-crypt\40ac6397-5116-4b44-8cb9-a2f70d8d68fa\videoHD\index.m3u8").stream().forEach(System.out::println); } }
大佬有話說 (0)