HLS标准加密需要配合密钥管理服务和令牌服务使用,本文为您介绍HLS标准加密的相关概念、准备工作和接入流程。

HLS加密解密流程

上传加密流程图加密播放解密流程图播放解密
说明 令牌服务和解密服务需要自行搭建。

相关概念

  • 密钥管理服务

    密钥管理服务(Key Management Service,简称KMS),是一种安全管理服务,主要负责数据密钥的生产、加密、解密等工作。

  • 访问控制

    访问控制(Resource Access Management 简称RAM)是阿里云为客户提供的用户身份管理与资源访问控制服务。

  • 数据密钥

    数据密钥(Data Key,简称DK)也称明文密钥。DK为加密数据使用的明文数据密钥。

  • 信封数据密钥

    信封数据密钥(Enveloped Data Key,简称EDK)也称密文密钥。EDK为通过信封加密技术保密后的密文数据密钥。

准备工作

  1. 开通视频点播服务并登录视频点播控制台,开启对应服务区域的存储空间,具体操作,请参见开通存储管理
  2. 在视频点播控制台上配置加速域名,并开启该域名视频相关中的HLS标准加密参数透,开启后MtsHlsUriToken参数可以重写。具体操作,请参见添加加速域名HLS标准加密参数透传
  3. 登录RAM访问控制,获取并保存AccessKey ID和AccessKey Secret。
  4. 开通密钥管理服务并登录KMS管理控制台。提交工单申请创建Service Key。Service Key与视频存储的源站区域必须一致,例如:视频存储在华东2,则Service Key必须是华东2。申请成功后,在业务方的KMS管理控制台中可以看到描述为vod的Service Key。如下图所示:Service Key
    说明 Service Key是密钥管理服务的一种加密主Key,接入标准加密的密钥必须要使用该Service Key生成。视频点播控制台暂不支持用户自主创建Service Key。
  5. 已搭建服务端SDK,具体操作,请参见服务端SDK安装

接入流程

  1. 添加加密模板和不转码模板。

    HLS标准加密转码需要创建两个转码模板:加密模板和不转码模板。

    不转码模板在开启对应服务区域的存储空间后会自动生成。
    注意 目前点播上传视频默认都会自动触发转码(自动触发暂不支持HLS标准加密)),因此对于标准加密为防止自动触发转码,需要先使用不转码模板上传视频(该类模板不会自动触发转码),然后再调用提交媒体转码作业接口发起标准加密转码。

    添加加密模板并保存加密模板ID,操作流程如下:

    1. 登录视频点播控制台
    2. 配置管理区域,选择媒体处理配置 > 转码模板组 > 添加转码模板组
    3. 添加转码模板组页面,输入模板组名称
    4. 普通转码模板区域单击添加模板开始创建转码模板。
    5. 基本参数区域,选择封装格式hls
    6. 视频参数区域、音频参数区域及条件转码参数区域,可根据实际需要配置相关参数,各参数的含义及配置限制请参见音视频转码
    7. 高级参数区域,开启视频加密,并勾选私有加密选项(系统默认勾选,否则不加密)。
      加密模板
      说明 该模板在调用提交媒体转码作业接口时,通过TemplateGroupId参数传递,如此视频点播将按照设置的模板和传递的密钥信息进行标准加密转码。
    8. 单击保存,自动返回转码模板组页面,获取并自行保存加密模板ID。
  2. RAM授权。

    使用RAM服务给视频点播授权访问业务方密钥管理服务的权限,进入RAM授权页面,单击同意授权

    授权页面
  3. 搭建密钥管理服务,封装阿里云密钥管理服务(KMS)。
    调用GenerateDataKey接口生成一个AES_128密钥,该接口只需要传KeyId(Service Key)和KeySpec(固定为:AES_128)即可,其他参数不用传,否则可能加密失败。

    调用成功后保存返回参数CiphertextBlob(密文密钥)的值。

    说明 使用密钥会产生费用,具体费用说明,请参见API调用费用
  4. 搭建令牌颁发服务,生成MtsHlsUriToken。
    Java示例代码以及示例代码需要手动变更的地方如下所示:
    • ENCRYPT_KEY:加密字符串,长度为16,用户自行定义。
    • INIT_VECTOR:自定义字符串,长度为16,不能含有特殊字符。
    • playToken.generateToken(""):自定义字符串,长度为16。

    最终代码所生成的Token即是MtsHlsUriToken。

    import com.sun.deploy.util.StringUtils;
    import org.apache.commons.codec.binary.Base64;
    
    import javax.crypto.Cipher;
    import javax.crypto.spec.IvParameterSpec;
    import javax.crypto.spec.SecretKeySpec;
    import java.util.Arrays;
    
    public class PlayToken {
        //非AES生成方式,无需以下参数
        private static String ENCRYPT_KEY = "";  //加密字符串,用户自行定义
        private static String INIT_VECTOR = "";  //长度为16的自定义字符串,不能有特殊字符
    
        public static void main(String[] args) throws Exception {
    
            PlayToken playToken = new PlayToken();
            playToken.generateToken("");
        }
    
        /**
         * 根据传递的参数生成令牌
         * 说明:
         *  1、参数可以是业务方的用户ID、播放终端类型等信息
         *  2、调用令牌接口时生成令牌Token
         * @param args
         * @return
         */
        public String generateToken(String... args) throws Exception {
            if (null == args || args.length <= 0) {
                return null;
            }
            String base = StringUtils.join(Arrays.asList(args), "_");
            //设置30S后,该token过期,过期时间可以自行调整
            long expire = System.currentTimeMillis() + 30000L;
            base += "_" + expire;   //自定义字符串,base的最终长度为16位字符(此例中,时间戳占13位,下划线(_)占1位,则还需传入2位字符。实际配置时也可按需全部更改,最终保证base为16位字符串即可。)
            //生成token
            String token = encrypt(base, ENCRYPT_KEY);
            System.out.println(token);
            //保存token,用于解密时校验token的有效性,例如:过期时间、token的使用次数
            saveToken(token);
            return token;
        }
        /**
         * 验证token的有效性
         * 说明:
         *  1、解密接口在返回播放密钥前,需要先校验Token的合法性和有效性
         *  2、强烈建议同时校验Token的过期时间以及Token的有效使用次数
         * @param token
         * @return
         * @throws Exception
         */
        public boolean validateToken(String token) throws Exception {
            if (null == token || "".equals(token)) {
                return false;
            }
            String base = decrypt(token, ENCRYPT_KEY);
            //先校验token的有效时间
            Long expireTime = Long.valueOf(base.substring(base.lastIndexOf("_") + 1));
            if (System.currentTimeMillis() > expireTime) {
                return false;
            }
            //从DB获取token信息,判断token的有效性,业务方可自行实现
            Token dbToken = getToken(token);
            //判断是否已经使用过该token
            if (dbToken == null || dbToken.useCount > 0) {
                return false;
            }
            //获取到业务属性信息,用于校验
            String businessInfo = base.substring(0, base.lastIndexOf("_"));
            String[] items = businessInfo.split("_");
            //校验业务信息的合法性,业务方实现
            return validateInfo(items);
        }
        /**
         * 保存Token到DB
         * 业务方自行实现
         *
         * @param token
         */
        public void saveToken(String token) {
            //TODO 存储Token
        }
        /**
         * 查询Token
         * 业务方自行实现
         *
         * @param token
         */
        public Token getToken(String token) {
            //TODO 从DB 获取Token信息,用于校验有效性和合法性
            return null;
        }
        /**
         * 校验业务信息的有效性,业务方可自行实现
         *
         * @param infos
         * @return
         */
        public boolean validateInfo(String... infos) {
            //TODO 校验信息的有效性,例如UID是否有效等
            return true;
        }
        /**
         * AES加密生成Token
         *
         * @param key
         * @param value
         * @return
         * @throws Exception
         */
        public String encrypt(String value, String key) throws Exception {
            IvParameterSpec e = new IvParameterSpec(INIT_VECTOR.getBytes("UTF-8"));
            SecretKeySpec skeySpec = new SecretKeySpec(key.getBytes("UTF-8"), "AES");
            Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5PADDING");
            cipher.init(Cipher.ENCRYPT_MODE, skeySpec, e);
            byte[] encrypted = cipher.doFinal(value.getBytes());
            return Base64.encodeBase64String(encrypted);
        }
        /**
         * AES解密token
         *
         * @param key
         * @param encrypted
         * @return
         * @throws Exception
         */
        public String decrypt(String encrypted, String key) throws Exception {
            IvParameterSpec e = new IvParameterSpec(INIT_VECTOR.getBytes("UTF-8"));
            SecretKeySpec skeySpec = new SecretKeySpec(key.getBytes("UTF-8"), "AES");
            Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5PADDING");
            cipher.init(Cipher.DECRYPT_MODE, skeySpec, e);
            byte[] original = cipher.doFinal(Base64.decodeBase64(encrypted));
            return new String(original);
        }
        /**
         * Token信息,业务方可提供更多信息,这里仅仅给出示例
         */
        class Token {
            //Token的有效使用次数,分布式环境需要注意同步修改问题
            int useCount;
            //token内容
            String token;
        }}
  5. 搭建解密服务。
    注意 解密服务在播放视频前就需要启动,否则视频无法正常解密。

    解密密钥EDK(密文密钥),调用Decrypt接口进行解密。如果业务方需要对解密接口进行安全验证,则需要提供令牌生成服务,生成的令牌能够在解密服务中被解析验证。

    解密接口返回的数据,是GenerateDataKey生成的两种密钥中的明文密钥(PlainText)经过base64decode之后的数据。

    Java示例代码以及示例代码需要手动变更的地方如下所示:
    • region:填写地域,例如华东2(上海),填写cn-shanghai
    • AccessKey:填写对应账号的AccessKey ID和AccessKey Secret。
    • httpserver:根据需求选择服务启动的端口号。
    import com.aliyuncs.DefaultAcsClient;
    import com.aliyuncs.exceptions.ClientException;
    import com.aliyuncs.http.ProtocolType;
    import com.aliyuncs.kms.model.v20160120.DecryptRequest;
    import com.aliyuncs.kms.model.v20160120.DecryptResponse;
    import com.aliyuncs.profile.DefaultProfile;
    import com.sun.net.httpserver.Headers;
    import com.sun.net.httpserver.HttpExchange;
    import com.sun.net.httpserver.HttpHandler;
    import com.sun.net.httpserver.HttpServer;
    import com.sun.net.httpserver.spi.HttpServerProvider;
    import org.apache.commons.codec.binary.Base64;
    import java.io.IOException;
    import java.io.OutputStream;
    import java.net.HttpURLConnection;
    import java.net.InetSocketAddress;
    import java.net.URI;import java.util.regex.Matcher;
    import java.util.regex.Pattern;
    public class HlsDecryptServer {
        private static DefaultAcsClient client;
        static {
            //KMS的区域,必须与视频对应区域
            String region = "";
            //访问KMS的授权AccessKey信息
            String accessKeyId = "";
            String accessKeySecret = "";
            client = new DefaultAcsClient(DefaultProfile.getProfile(region, accessKeyId, accessKeySecret));
        }
        /**
         * 说明:
         * 1、接收解密请求,获取密文密钥和令牌Token
         * 2、调用KMS decrypt接口获取明文密钥
         * 3、将明文密钥base64decode返回
         */
        public class HlsDecryptHandler implements HttpHandler {
            /**
             * 处理解密请求
             * @param httpExchange
             * @throws IOException
             */
            public void handle(HttpExchange httpExchange) throws IOException {
                String requestMethod = httpExchange.getRequestMethod();
                if ("GET".equalsIgnoreCase(requestMethod)) {
                    //校验token的有效性
                    String token = getMtsHlsUriToken(httpExchange);
                    boolean validRe = validateToken(token);
                    if (!validRe) {
                        return;
                    }
                    //从URL中取得密文密钥
                    String ciphertext = getCiphertext(httpExchange);
                    if (null == ciphertext)
                        return;
                    //从KMS中解密出来,并Base64 decode
                    byte[] key = decrypt(ciphertext);
                    //设置header
                    setHeader(httpExchange, key);
                    //返回base64decode之后的密钥
                    OutputStream responseBody = httpExchange.getResponseBody();
                    responseBody.write(key);
                    responseBody.close();
                }
            }
            private void setHeader(HttpExchange httpExchange, byte[] key) throws IOException {
                Headers responseHeaders = httpExchange.getResponseHeaders();
                responseHeaders.set("Access-Control-Allow-Origin", "*");
                httpExchange.sendResponseHeaders(HttpURLConnection.HTTP_OK, key.length);
            }
            /**
             * 调用KMS decrypt接口解密,并将明文base64decode
             * @param ciphertext
             * @return
             */
            private byte[] decrypt(String ciphertext) {
                DecryptRequest request = new DecryptRequest();
                request.setCiphertextBlob(ciphertext);
                request.setProtocol(ProtocolType.HTTPS);
                try {
                    DecryptResponse response = client.getAcsResponse(request);
                    String plaintext = response.getPlaintext();
                    //注意:需要base64 decode
                    return Base64.decodeBase64(plaintext);
                } catch (ClientException e) {
                    e.printStackTrace();
                    return null;
                }
            }
            /**
             * 校验令牌有效性
             * @param token
             * @return
             */
            private boolean validateToken(String token) {
                if (null == token || "".equals(token)) {
                    return false;
                }
                //TODO 业务方实现令牌有效性校验
                return true;
            }
            /**
             * 从URL中获取密文密钥参数
             * @param httpExchange
             * @return
             */
            private String getCiphertext(HttpExchange httpExchange) {
                URI uri = httpExchange.getRequestURI();
                String queryString = uri.getQuery();
                String pattern = "CipherText=(\\w*)";
                Pattern r = Pattern.compile(pattern);
                Matcher m = r.matcher(queryString);
                if (m.find())
                    return m.group(1);
                else {
                    System.out.println("Not Found CipherText Param");
                    return null;
                }
            }
            /**
             * 获取Token参数
             *
             * @param httpExchange
             * @return
             */
            private String getMtsHlsUriToken(HttpExchange httpExchange) {
                URI uri = httpExchange.getRequestURI();
                String queryString = uri.getQuery();
                String pattern = "MtsHlsUriToken=(\\w*)";
                Pattern r = Pattern.compile(pattern);
                Matcher m = r.matcher(queryString);
                if (m.find())
                    return m.group(1);
                else {
                    System.out.println("Not Found MtsHlsUriToken Param");
                    return null;
                }
            }
        }
        /**
         * 服务启动
         *
         * @throws IOException
         */
        private void serviceBootStrap() throws IOException {
            HttpServerProvider provider = HttpServerProvider.provider();
            //监听端口可以自定义,能同时接受最多30个请求
            HttpServer httpserver = provider.createHttpServer(new InetSocketAddress(8099), 30);
            httpserver.createContext("/", new HlsDecryptHandler());
            httpserver.start();
            System.out.println("hls decrypt server started");
        }
        public static void main(String[] args) throws IOException {
            HlsDecryptServer server = new HlsDecryptServer();
            server.serviceBootStrap();
        }}
  6. 上传视频。

    使用不转码模板创建视频上传凭证和地址。控制台具体操作,请参见媒资上传;服务端接口上传,请参见获取视频上传地址和凭证

  7. 接收上传完成回调消息。
    通过设置事件通知配置设置回调,通过查询事件通知配置查询回调消息,当接收到视频上传完成的回调消息,则表明文件已经上传到视频点播。
  8. 发起标准加密转码。
    调用提交媒体转码作业接口发起标准加密转码。
    Java示例代码以及示例代码需要手动变更的地方如下所示:
    • request.setTemplateGroupId(""):传入加密模板ID。
    • request.setVideoId(""):传入视频ID。
    • encryptConfig.put("CipherText",""):传入步骤三获取的CiphertextBlob值。
    • encryptConfig.put("DecryptKeyUri",""):传入播放地址、CiphertextBlob值以及MtsHlsUriToken。以在本地的8099端口播放为例,播放地址为:http://127.0.0.1:8099?CipherText=CiphertextBlob值&MtsHlsUriToken=MtsHlsUriToken值
    import com.alibaba.fastjson.JSON;
    import com.alibaba.fastjson.JSONObject;
    import com.aliyuncs.DefaultAcsClient;
    import com.aliyuncs.exceptions.ClientException;
    import com.aliyuncs.profile.DefaultProfile;
    import com.aliyuncs.vod.model.v20170321.SubmitTranscodeJobsRequest;
    import com.aliyuncs.vod.model.v20170321.SubmitTranscodeJobsResponse;
    
    public class SubmitTranscodeJobs {
    
        private static String accessKeyId = "accessKeyId";
        private static String accessKeySecret = "accessKeySecret";
    
        public static SubmitTranscodeJobsResponse submitTranscodeJobs(DefaultAcsClient client) throws Exception{
            SubmitTranscodeJobsRequest request = new SubmitTranscodeJobsRequest();
            request.setTemplateGroupId("");
            request.setVideoId("");
            JSONObject encryptConfig = new JSONObject();
            encryptConfig.put("CipherText","");
            encryptConfig.put("DecryptKeyUri","");
            encryptConfig.put("KeyServiceType","KMS");
            request.setEncryptConfig(encryptConfig.toJSONString());
            return client.getAcsResponse(request);
        }
    
        public static void main(String[] args) throws ClientException {
            String regionId = "cn-shanghai";  // 点播服务接入区域
            DefaultProfile profile = DefaultProfile.getProfile(regionId, accessKeyId, accessKeySecret);
            DefaultAcsClient client = new DefaultAcsClient(profile);
    
            SubmitTranscodeJobsResponse response;
            try {
                response = submitTranscodeJobs(client);
                System.out.println("RequestId is:"+response.getRequestId());
                System.out.println("TranscodeTaskId is:"+response.getTranscodeTaskId());
                System.out.println("TranscodeJobs is:"+ JSON.toJSON(response.getTranscodeJobs()));
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }       
  9. 验证加密转码是否成功。

    您可以登录视频点播控制台查看该视频的视频地址,通过以下三种方式来逐步判断标准加密是否成功。

    • 当视频加密转码后,如果该视频的视频地址只有一个M3U8格式的视频地址,那么该视频状态为转码失败
    • 当视频加密转码后,如果视频不只有M3U8格式的输出(例如还存在格式为MP4的原始文件),只需查看M3U8格式后是否带有私有加密,一般情况下,如果存在则表明标准加密已成功。
    • 如果以上两种方式都不能判断,那么可以将带有加密标志的M3U8文件的地址拷贝出来,使用curl -v "M3U8文件地址",查看获取到的M3U8内容是否存在URI="<业务方在发起标准加密时传递的解密地址,即加密配置 EncryptConfig中的DecryptKeyUri参数值>"关键信息,有则表明为标准加密且加密成功。

播放流程

  1. 获取视频的播放地址和凭证。

    调用获取视频播放地址获取视频播放凭证接口获取视频的播放地址和凭证。

  2. 传入认证信息。

    获取M3U8文件地址后,播放器会解析M3U8文件中的EXT-X-KEY标签中的URI并访问,从而获取到带密文密钥的解密接口URI,此URI为您发起标准加密时传递的加密配置 EncryptConfig中的DecryptKeyUri参数值。

    若只允许合法用户才可以访问,那么需要播放器在获取解密密钥时携带您承认的认证信息,认证信息可以通过MtsHlsUriToken参数传入。

    示例:

    • 视频的播放地址为:https://vod.demo.com/encrypt-stream-hd.m3u8,则请求时需要携带MtsHlsUriToken参数传入。
    • 最终请求地址为:https://vod.demo.com/encrypt-stream-hd.m3u8?MtsHlsUriToken=<令牌>
    • 解密地址为:https://vod.decrypt.com?Ciphertext=ZjJmZGViNzUtZWY1Mi00Y2RlLTk3MTMtOTYyOT
    • 最终解密请求地址为:https://vod.decrypt.com?Ciphertext=ZjJmZGViNzUtZWY1Mi00Y2RlLTk3MTMtOTYyOT&MtsHlsUriToken=<颁发的令牌>
  3. 播放。
    播放器在解析到解密地址URI时会自动请求解密接口获取解密密钥,拿到解密密钥去解密加密过的ts文件进行播放。