Skip to content

Commit

Permalink
feat: eccp support including on-the-fly encoding and laurl server
Browse files Browse the repository at this point in the history
  • Loading branch information
tobbee committed Jan 26, 2024
1 parent 8b57bb3 commit 70f43ef
Show file tree
Hide file tree
Showing 17 changed files with 758 additions and 69 deletions.
4 changes: 4 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
"autodetected",
"autoplay",
"caddyserver",
"cbcs",
"cenc",
"certmagic",
"certpath",
"cfhd",
Expand All @@ -46,6 +48,7 @@
"httpisoms",
"httpxsdatems",
"IDURI",
"IFECCP",
"imsc",
"Inband",
"insertad",
Expand All @@ -57,6 +60,7 @@
"knadh",
"koanf",
"Konf",
"laurl",
"livesim",
"logfile",
"logformat",
Expand Down
114 changes: 105 additions & 9 deletions cmd/livesim2/app/asset.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
package app

import (
"bytes"
"compress/gzip"
"encoding/hex"
"encoding/json"
"fmt"
"io"
Expand Down Expand Up @@ -659,11 +659,27 @@ type RepData struct {
Segments []Segment `json:"segments"`
DefaultSampleDuration uint32 `json:"defaultSampleDuration"` // Read from trex or tfhd
ConstantSampleDuration *uint32 `json:"constantSampleDuration,omitempty"` // Non-zero if all samples have the same duration
PreEncrypted bool `json:"preEncrypted"`
mediaRegexp *regexp.Regexp `json:"-"`
initSeg *mp4.InitSegment `json:"-"`
initBytes []byte `json:"-"`
encData *repEncData `json:"-"`
}

type repEncData struct {
keyID id16 // Should be common within one AdaptationSet, but for now common for one asset
key id16 // Should be common within one AdaptationSet, but for now common for one asset
iv []byte // Can be random, but we use a constant default value at start
cbcsPD *mp4.InitProtectData
cencPD *mp4.InitProtectData
cbcsInitSeg *mp4.InitSegment
cencInitSeg *mp4.InitSegment
cbcsInitBytes []byte
cencInitBytes []byte
}

var defaultIV = []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07}

func (r RepData) duration() int {
if len(r.Segments) == 0 {
return 0
Expand Down Expand Up @@ -719,24 +735,23 @@ func (r RepData) typeURI() mediaURIType {
}
}

func prepareForEncryption(codec string) bool {
return strings.HasPrefix(codec, "avc") || strings.HasPrefix(codec, "mp4a.40")
}

func (r *RepData) readInit(vodFS fs.FS, assetPath string) error {
data, err := fs.ReadFile(vodFS, path.Join(assetPath, r.InitURI))
if err != nil {
return fmt.Errorf("read initURI %q: %w", r.InitURI, err)
}
sr := bits.NewFixedSliceReader(data)
initFile, err := mp4.DecodeFileSR(sr)
r.initSeg, err = getInitSeg(data)
if err != nil {
return fmt.Errorf("decode init: %w", err)
}
r.initSeg = initFile.Init
b := make([]byte, 0, r.initSeg.Size())
buf := bytes.NewBuffer(b)
err = r.initSeg.Encode(buf)
r.initBytes, err = getInitBytes(r.initSeg)
if err != nil {
return fmt.Errorf("encode init seg: %w", err)
return fmt.Errorf("getInitBytes: %w", err)
}
r.initBytes = buf.Bytes()

if r.MediaTimescale != 0 {
return nil // Already set
Expand All @@ -745,9 +760,90 @@ func (r *RepData) readInit(vodFS fs.FS, assetPath string) error {
r.MediaTimescale = int(r.initSeg.Moov.Trak.Mdia.Mdhd.Timescale)
trex := r.initSeg.Moov.Mvex.Trex
r.DefaultSampleDuration = trex.DefaultSampleDuration

if prepareForEncryption(r.Codecs) {
assetName := path.Base(assetPath)
err = r.addEncryption(assetName, data)
if err != nil {
return fmt.Errorf("addEncryption: %w", err)
}
}
return nil
}

func (r *RepData) addEncryption(assetName string, data []byte) error {
// Set up the encryption data for this representation given asset
ed := repEncData{}
ed.keyID = kidFromString(assetName)
ed.key = kidToKey(ed.keyID)
ed.iv = defaultIV

// Generate cbcs data or exit if already encrypted
initSeg, err := getInitSeg(data)
if err != nil {
return fmt.Errorf("decode init: %w", err)
}
stsd := initSeg.Moov.Trak.Mdia.Minf.Stbl.Stsd
for _, c := range stsd.Children {
switch c.Type() {
case "encv", "enca":
slog.Info("asset", assetName, "repID", r.ID, "Init segment already encrypted")
r.PreEncrypted = true
return nil
}
}
kid, err := mp4.NewUUIDFromHex(hex.EncodeToString(ed.keyID[:]))
if err != nil {
return fmt.Errorf("new uuid: %w", err)
}
ipd, err := mp4.InitProtect(initSeg, nil, ed.iv, "cbcs", kid, nil)
if err != nil {
return fmt.Errorf("init protect cbcs: %w", err)
}
ed.cbcsPD = ipd
ed.cbcsInitSeg = initSeg
ed.cbcsInitBytes, err = getInitBytes(initSeg)
if err != nil {
return fmt.Errorf("getInitBytes: %w", err)
}

// Generate cenc data
initSeg, err = getInitSeg(data)
if err != nil {
return fmt.Errorf("decode init: %w", err)
}
ipd, err = mp4.InitProtect(initSeg, nil, ed.iv, "cenc", kid, nil)
if err != nil {
return fmt.Errorf("init protect cenc: %w", err)
}
ed.cencPD = ipd
ed.cencInitSeg = initSeg
ed.cencInitBytes, err = getInitBytes(initSeg)
if err != nil {
return fmt.Errorf("getInitBytes: %w", err)
}
r.encData = &ed
return nil
}

func getInitSeg(data []byte) (*mp4.InitSegment, error) {
sr := bits.NewFixedSliceReader(data)
initFile, err := mp4.DecodeFileSR(sr)
if err != nil {
return nil, fmt.Errorf("decode init: %w", err)
}
return initFile.Init, nil
}

func getInitBytes(initSeg *mp4.InitSegment) ([]byte, error) {
sw := bits.NewFixedSliceWriter(int(initSeg.Size()))
err := initSeg.EncodeSW(sw)
if err != nil {
return nil, fmt.Errorf("encode init: %w", err)
}
return sw.Bytes(), nil
}

// readMP4Segment extracts segment data and returns an error if file does not exist.
func (r *RepData) readMP4Segment(vodFS fs.FS, assetPath string, time uint64, nr uint32) (Segment, error) {
var seg Segment
Expand Down
15 changes: 12 additions & 3 deletions cmd/livesim2/app/configurl.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,10 @@ type ResponseConfig struct {
TimeSubsDurMS int `json:"TimeSubsDurMS,omitempty"`
TimeSubsRegion int `json:"TimeSubsRegion,omitempty"`
Host string `json:"Host,omitempty"`
SegStatusCodes []SegStatusCodes `json:"SegStatus,omitempty"`
Traffic []LossItvls `json:"Traffic,omitempty"`
// DashIFECCP is DASH-IF Enhanced Clear Key Content Protection
DashIFECCP string `json:"ECCP,omitempty"`
SegStatusCodes []SegStatusCodes `json:"SegStatus,omitempty"`
Traffic []LossItvls `json:"Traffic,omitempty"`
}

// SegStatusCodes configures regular extraordinary segment response codes
Expand Down Expand Up @@ -371,6 +373,8 @@ cfgLoop:
cfg.SegStatusCodes = sc.ParseSegStatusCodes(key, val)
case "traffic":
cfg.Traffic = sc.ParseLossItvls(key, val)
case "eccp":
cfg.DashIFECCP = val
default:
contentStartIdx = i
break cfgLoop
Expand Down Expand Up @@ -419,7 +423,12 @@ func verifyAndFillConfig(cfg *ResponseConfig, nowMS int) error {
return err
}
}

switch cfg.DashIFECCP {
case "", "cenc", "cbcs":
// OK
default:
return fmt.Errorf("invalid DASH-IF Enhanced Clear Key Content Protection eccp %q", cfg.DashIFECCP)
}
return nil
}

Expand Down
2 changes: 1 addition & 1 deletion cmd/livesim2/app/handler_index.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,6 @@ func (s *Server) favIconFunc(w http.ResponseWriter, r *http.Request) {

// optionsHandlerFunc provides the allowed methods.
func (s *Server) optionsHandlerFunc(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Allow", "OPTIONS, GET, HEAD")
w.Header().Set("Allow", "OPTIONS, GET, HEAD, POST")
w.WriteHeader(http.StatusNoContent)
}
66 changes: 66 additions & 0 deletions cmd/livesim2/app/handler_larurl_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package app

import (
"testing"

"github.com/stretchr/testify/require"
)

func TestParseLaURLBody(t *testing.T) {
cases := []struct {
name string
body string
expectedHexKeys []id16
}{
{
name: "dashif-example",
body: `{"kids":["nrQFDeRLSAKTLifXUIPiZg"],"type":"temporary"}`,
expectedHexKeys: []id16{MustKey16FromHex("9eb4050de44b4802932e27d75083e266")},
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
hexKIDs, err := parseLaURLBody([]byte(c.body))
if err != nil {
t.Error(err)
}
require.Equal(t, c.expectedHexKeys, hexKIDs)
})
}
}

func MustKey16FromHex(hexStr string) id16 {
k, err := id16FromHex(hexStr)
if err != nil {
panic(err)
}
return k
}

func TestGenerateLAResponse(t *testing.T) {
cases := []struct {
name string
key id16
keyID id16
expectedResp LaURLResponse
}{
{
name: "dashif-example",
key: MustKey16FromHex("9eb4050de44b4802932e27d75083e266"),
keyID: MustKey16FromHex("9eb4050de44b4802932e27d75083e266"),
expectedResp: LaURLResponse{Type: "temporary",
Keys: []CCPKey{
{Kty: "oct",
K: "nrQFDeRLSAKTLifXUIPiZg",
Kid: "nrQFDeRLSAKTLifXUIPiZg"},
},
},
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
resp := generateLaURLResponse([]keyAndID{{key: c.key, id: c.keyID}})
require.Equal(t, c.expectedResp, resp)
})
}
}
Loading

0 comments on commit 70f43ef

Please sign in to comment.