Skip to content

Commit

Permalink
feat(wv): add cdm
Browse files Browse the repository at this point in the history
  • Loading branch information
iyear committed Oct 22, 2023
1 parent 7f71d65 commit 69d3b47
Show file tree
Hide file tree
Showing 10 changed files with 552 additions and 1 deletion.
281 changes: 281 additions & 0 deletions cdm.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,281 @@
package widevine

import (
"crypto"
"crypto/aes"
"crypto/cipher"
"crypto/hmac"
"crypto/rsa"
"crypto/sha1"
"crypto/sha256"
"encoding/binary"
"fmt"
"math/rand"
"time"

"google.golang.org/protobuf/proto"

"github.com/iyear/gowidevine/device"
wvpb "github.com/iyear/gowidevine/widevinepb"
)

const (
sessionKeyLength = 16
)

type Key struct {
Type wvpb.License_KeyContainer_KeyType
ID []byte
Key []byte
}

type CDM struct {
device device.Device
rand *rand.Rand
now func() time.Time
}

type CDMOption func(*CDM)

func defaultCDMOptions() []CDMOption {
l3device := device.L3[rand.Intn(len(device.L3))]

return []CDMOption{
WithDevice(l3device),
WithRandom(rand.NewSource(time.Now().UnixNano())),
WithNow(time.Now),
}
}

func WithDevice(device device.Device) CDMOption {
return func(c *CDM) {
c.device = device
}
}

func WithRandom(source rand.Source) CDMOption {
return func(c *CDM) {
c.rand = rand.New(source)
}
}

func WithNow(now func() time.Time) CDMOption {
return func(c *CDM) {
c.now = now
}
}

func NewCDM(opts ...CDMOption) *CDM {
c := &CDM{}

for _, opt := range defaultCDMOptions() {
opt(c)
}

for _, opt := range opts {
opt(c)
}

return c
}

func (c *CDM) GetLicenseChallenge(pssh *PSSH, typ wvpb.LicenseType, privacyMode bool, serviceCert ...*wvpb.DrmCertificate) ([]byte, func(b []byte) ([]*Key, error), error) {
req := &wvpb.LicenseRequest{
Type: wvpb.LicenseRequest_NEW.Enum(),
RequestTime: ptr(c.now().Unix()),
ProtocolVersion: wvpb.ProtocolVersion_VERSION_2_1.Enum(),
KeyControlNonce: ptr(c.rand.Uint32()),
ContentId: &wvpb.LicenseRequest_ContentIdentification{
ContentIdVariant: &wvpb.LicenseRequest_ContentIdentification_WidevinePsshData_{
WidevinePsshData: &wvpb.LicenseRequest_ContentIdentification_WidevinePsshData{
PsshData: [][]byte{pssh.RawData()},
LicenseType: typ.Enum(),
RequestId: []byte(fmt.Sprintf("%08X%08X0100000000000000",
c.rand.Uint32(),
c.rand.Uint32())),
},
},
},
}

// set client id
if privacyMode {
if len(serviceCert) == 0 {
return nil, nil, fmt.Errorf("privacy mode must provide cert")
}

cert := serviceCert[0]
encClientID, err := c.encryptClientID(cert)
if err != nil {
return nil, nil, fmt.Errorf("encrypt client id: %w", err)
}

req.EncryptedClientId = encClientID
} else {
req.ClientId = c.device.ClientID
}

reqData, err := proto.Marshal(req)
if err != nil {
return nil, nil, fmt.Errorf("marshal license request: %w", err)
}

// signed license request signature
hashed := sha1.Sum(reqData)
pss, err := rsa.SignPSS(
rand.New(c.rand),
c.device.PrivateKey,
crypto.SHA1,
hashed[:],
&rsa.PSSOptions{SaltLength: rsa.PSSSaltLengthEqualsHash})
if err != nil {
return nil, nil, fmt.Errorf("sign pss: %w", err)
}

msg := &wvpb.SignedMessage{
Type: wvpb.SignedMessage_LICENSE_REQUEST.Enum(),
Msg: reqData,
Signature: pss,
}

data, err := proto.Marshal(msg)
if err != nil {
return nil, nil, fmt.Errorf("marshal signed message: %w", err)
}

return data, func(license []byte) ([]*Key, error) {
return c.parseLicense(license, reqData)
}, nil
}

func (c *CDM) encryptClientID(cert *wvpb.DrmCertificate) (*wvpb.EncryptedClientIdentification, error) {
privacyKey := c.randomBytes(16)
privacyIV := c.randomBytes(16)

block, err := aes.NewCipher(privacyKey)
if err != nil {
return nil, fmt.Errorf("new cipher: %w", err)
}

// encryptedClientID
clientID, err := proto.Marshal(c.device.ClientID)
if err != nil {
return nil, fmt.Errorf("marshal client id: %w", err)
}
paddedData := pkcs7Padding(clientID, aes.BlockSize)
mode := cipher.NewCBCEncrypter(block, privacyIV)
encryptedClientID := make([]byte, len(paddedData))
mode.CryptBlocks(encryptedClientID, paddedData)

// encryptedPrivacyKey
publicKey, err := parsePublicKey(cert.PublicKey)
if err != nil {
return nil, fmt.Errorf("parse public key: %w", err)
}
encryptedPrivacyKey, err := rsa.EncryptOAEP(
sha1.New(),
c.rand,
publicKey,
privacyKey,
nil)
if err != nil {
return nil, fmt.Errorf("encrypt oaep: %w", err)
}

encClientID := &wvpb.EncryptedClientIdentification{
ProviderId: cert.ProviderId,
ServiceCertificateSerialNumber: cert.SerialNumber,
EncryptedClientId: encryptedClientID,
EncryptedPrivacyKey: encryptedPrivacyKey,
EncryptedClientIdIv: privacyIV,
}

return encClientID, nil
}

func (c *CDM) randomBytes(length int) []byte {
r := make([]byte, length)
c.rand.Read(r)
return r
}

func (c *CDM) parseLicense(license, licenseRequest []byte) ([]*Key, error) {
signedMsg := &wvpb.SignedMessage{}
if err := proto.Unmarshal(license, signedMsg); err != nil {
return nil, fmt.Errorf("unmarshal signed message: %w", err)
}
if signedMsg.GetType() != wvpb.SignedMessage_LICENSE {
return nil, fmt.Errorf("invalid license type: %v", signedMsg.GetType())
}

sessionKey, err := c.rsaOAEPDecrypt(c.device.PrivateKey, signedMsg.SessionKey)
if err != nil {
return nil, fmt.Errorf("decrypt session key: %w", err)
}
if len(sessionKey) != sessionKeyLength {
return nil, fmt.Errorf("invalid session key length: %v", sessionKey)
}

derivedEncKey := deriveEncKey(licenseRequest, sessionKey)
derivedAuthKey := deriveAuthKey(licenseRequest, sessionKey)

licenseMsg := &wvpb.License{}
if err = proto.Unmarshal(signedMsg.Msg, licenseMsg); err != nil {
return nil, fmt.Errorf("unmarshal license message: %w", err)
}

licenseMsgHMAC := hmac.New(sha256.New, derivedAuthKey)
licenseMsgHMAC.Write(signedMsg.Msg)
expectedHMAC := licenseMsgHMAC.Sum(nil)
if !hmac.Equal(signedMsg.Signature, expectedHMAC) {
return nil, fmt.Errorf("invalid license signature: %v", signedMsg.Signature)
}

keys := make([]*Key, 0)
for _, key := range licenseMsg.Key {
decryptedKey, err := decryptAES(derivedEncKey, key.Iv, key.Key)
if err != nil {
return nil, fmt.Errorf("decrypt aes: %w", err)
}

keys = append(keys, &Key{
Type: key.GetType(),
ID: key.GetId(),
Key: decryptedKey,
})
}

return keys, nil
}

func (c *CDM) rsaOAEPDecrypt(privateKey *rsa.PrivateKey, encryptedData []byte) ([]byte, error) {
decryptedData, err := rsa.DecryptOAEP(sha1.New(), c.rand, privateKey, encryptedData, nil)
if err != nil {
return nil, err
}
return decryptedData, nil
}

func deriveEncKey(licenseRequest, sessionKey []byte) []byte {
encKey := make([]byte, 16+len(licenseRequest))

copy(encKey[:12], "\x01ENCRYPTION\x00")
copy(encKey[12:], licenseRequest)
binary.BigEndian.PutUint32(encKey[12+len(licenseRequest):], 128)

return cmacAES(encKey, sessionKey)
}

func deriveAuthKey(licenseRequest, sessionKey []byte) []byte {
authKey := make([]byte, 20+len(licenseRequest))

copy(authKey[:16], "\x01AUTHENTICATION\x00")
copy(authKey[16:], licenseRequest)
binary.BigEndian.PutUint32(authKey[16+len(licenseRequest):], 512)

authCmacKey1 := cmacAES(authKey, sessionKey)
authKey[0] = 2
authCmacKey2 := cmacAES(authKey, sessionKey)

return append(authCmacKey1, authCmacKey2...)
}
75 changes: 75 additions & 0 deletions cdm_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package widevine

import (
_ "embed"
"encoding/hex"
"testing"
"time"

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

"github.com/iyear/gowidevine/device"
wvpb "github.com/iyear/gowidevine/widevinepb"
)

var l3cdm device.Device

func init() {
for _, l3 := range device.L3 {
if l3.SystemID == 4464 {
l3cdm = l3
break
}
}
}

func TestRandomBytes(t *testing.T) {
cdm := NewCDM()

assert.Equal(t, 16, len(cdm.randomBytes(16)))
assert.Equal(t, 32, len(cdm.randomBytes(32)))
}

type fakeSource struct{}

func (f fakeSource) Int63() int64 { return 0 }
func (f fakeSource) Seed(_ int64) {}

//go:embed testdata/pssh
var psshData []byte

//go:embed testdata/license-challenge
var licenseChallenge []byte

//go:embed testdata/license
var license []byte

func TestNewCDM(t *testing.T) {
cdm := NewCDM(
WithDevice(l3cdm),
WithRandom(fakeSource{}),
WithNow(func() time.Time { return time.Unix(0, 0) }),
)
require.NotNil(t, cdm)

pssh, err := NewPSSH(psshData)
require.NoError(t, err)

cert, err := ParseServiceCert(serviceCert)
require.NoError(t, err)

challenge, parseLicense, err := cdm.GetLicenseChallenge(pssh, wvpb.LicenseType_AUTOMATIC, true, cert)
require.NoError(t, err)

require.Equal(t, licenseChallenge, challenge)

// parse license
keys, err := parseLicense(license)
require.NoError(t, err)

require.Len(t, keys, 1)
assert.Equal(t, wvpb.License_KeyContainer_CONTENT, keys[0].Type)
assert.Equal(t, "df6ef2f5fd83078091a78566c8d01925", hex.EncodeToString(keys[0].ID))
assert.Equal(t, "20be4041a33c7a081e43b2b4378d6d5c", hex.EncodeToString(keys[0].Key))
}
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/iyear/gowidevine

go 1.17
go 1.18

require (
github.com/Eyevinn/mp4ff v0.39.0
Expand All @@ -9,6 +9,7 @@ require (
)

require (
github.com/chmike/cmac-go v1.1.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
github.com/Eyevinn/mp4ff v0.39.0 h1:WV2Omq57y1BvcVPayjyuiIK8/pF5Tb/H/cgPY+wFZMQ=
github.com/Eyevinn/mp4ff v0.39.0/go.mod h1:w/6GSa5ghZ1VavzJK6McQ2/flx8mKtcrKDr11SsEweA=
github.com/chmike/cmac-go v1.1.0 h1:aF73ZAEx9N2WdQc93DOJ2fMsBDAGqUtuenjMJMb3kEI=
github.com/chmike/cmac-go v1.1.0/go.mod h1:wcIN7NRqWSKGuORzd4dReBkoBDE9ZBqfyTVxyDxGeUw=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
Expand Down
Binary file added testdata/license
Binary file not shown.
Binary file added testdata/license-challenge
Binary file not shown.
Binary file added testdata/pssh
Binary file not shown.
Binary file added testdata/service-cert
Binary file not shown.
Loading

0 comments on commit 69d3b47

Please sign in to comment.