M3u8 文件解析和 TS 文件加解密,不知道对不对

2020-08-27 14:51:03 +08:00
 yuyujulin
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);
    }
}

1573 次点击
所在节点    Java
0 条回复

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/701901

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX