Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 33 additions & 10 deletions sdp/offer.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,12 @@ func appendCryptoProfiles(attrs []sdp.Attribute, profiles []srtp.Profile) []sdp.
}

// OfferMediaWith creates a new SDP media description with a given codec set, public IP address and listening port.
func OfferMediaWith(s *media.CodecSet, rtpListenerPort int, encrypted Encryption) (MediaDesc, *sdp.MediaDescription, error) {
func OfferMediaWith(s *media.CodecSet, rtpListenerPort int, encrypted Encryption, opts ...NegotiationOption) (MediaDesc, *sdp.MediaDescription, error) {
opt := &Options{}
for _, o := range opts {
o(opt)
}

// Static compiler check for frame duration hardcoded below.
var _ = [1]struct{}{}[20*time.Millisecond-rtp.DefFrameDur]

Expand Down Expand Up @@ -152,7 +157,7 @@ func OfferMediaWith(s *media.CodecSet, rtpListenerPort int, encrypted Encryption
var cryptoProfiles []srtp.Profile
if encrypted != EncryptionNone {
var err error
cryptoProfiles, err = srtp.DefaultProfiles()
cryptoProfiles, err = opt.Srtp.LocalProfiles()
if err != nil {
return MediaDesc{}, nil, err
}
Expand Down Expand Up @@ -187,8 +192,8 @@ func OfferMediaWith(s *media.CodecSet, rtpListenerPort int, encrypted Encryption
// OfferMedia creates a new SDP media description.
//
// Deprecated: use OfferMediaWith
func OfferMedia(rtpListenerPort int, encrypted Encryption) (MediaDesc, *sdp.MediaDescription, error) {
return OfferMediaWith(media.GlobalCodecs(), rtpListenerPort, encrypted)
func OfferMedia(rtpListenerPort int, encrypted Encryption, opts ...NegotiationOption) (MediaDesc, *sdp.MediaDescription, error) {
return OfferMediaWith(media.GlobalCodecs(), rtpListenerPort, encrypted, opts...)
}

// AnswerMedia creates a new SDP media description for an answer.
Expand Down Expand Up @@ -245,11 +250,25 @@ type Offer Description

type Answer Description

type Options struct {
Srtp srtp.Options
}

type NegotiationOption func(*Options)

func WithLocalProfiles(profiles []srtp.Profile) NegotiationOption {
return func(o *Options) {
if len(profiles) != 0 {
o.Srtp.Profiles = profiles
}
}
}

// NewOfferWith creates a new SDP offer with a given codec set, public IP address and listening port.
func NewOfferWith(s *media.CodecSet, publicIp netip.Addr, rtpListenerPort int, encrypted Encryption) (*Offer, error) {
func NewOfferWith(s *media.CodecSet, publicIp netip.Addr, rtpListenerPort int, encrypted Encryption, opts ...NegotiationOption) (*Offer, error) {
sessId := rand.Uint64() // TODO: do we need to track these?

m, mediaDesc, err := OfferMediaWith(s, rtpListenerPort, encrypted)
m, mediaDesc, err := OfferMediaWith(s, rtpListenerPort, encrypted, opts...)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -289,12 +308,16 @@ func NewOfferWith(s *media.CodecSet, publicIp netip.Addr, rtpListenerPort int, e
// NewOffer creates a new SDP offer.
//
// Deprecated: use NewOfferWith
func NewOffer(publicIp netip.Addr, rtpListenerPort int, encrypted Encryption) (*Offer, error) {
return NewOfferWith(media.GlobalCodecs(), publicIp, rtpListenerPort, encrypted)
func NewOffer(publicIp netip.Addr, rtpListenerPort int, encrypted Encryption, opts ...NegotiationOption) (*Offer, error) {
return NewOfferWith(media.GlobalCodecs(), publicIp, rtpListenerPort, encrypted, opts...)
}

// Answer generates an SDP answer for an offer.
func (d *Offer) Answer(publicIp netip.Addr, rtpListenerPort int, enc Encryption) (*Answer, *MediaConfig, error) {
func (d *Offer) Answer(publicIp netip.Addr, rtpListenerPort int, enc Encryption, opts ...NegotiationOption) (*Answer, *MediaConfig, error) {
opt := &Options{}
for _, o := range opts {
o(opt)
}
audio, err := SelectAudio(d.MediaDesc, false)
if err != nil {
return nil, nil, err
Expand All @@ -305,7 +328,7 @@ func (d *Offer) Answer(publicIp netip.Addr, rtpListenerPort int, enc Encryption)
sprof *srtp.Profile
)
if len(d.CryptoProfiles) != 0 && enc != EncryptionNone {
answer, err := srtp.DefaultProfiles()
answer, err := opt.Srtp.LocalProfiles()
if err != nil {
return nil, nil, err
}
Expand Down
113 changes: 113 additions & 0 deletions sdp/offer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
package sdp_test

import (
"encoding/base64"
"net"
"net/netip"
"slices"
Expand Down Expand Up @@ -1068,3 +1069,115 @@ a=inactive
})
}
}

// AnswerWith must advertise exactly the local key material it configures, and must
// keep both stable when the same profiles are passed for a re-offer.
func TestAnswerWithLocalProfiles(t *testing.T) {
g := codecSet()
ip := netip.MustParseAddr("127.0.0.1")

offer, err := NewOfferWith(g, netip.MustParseAddr("1.1.1.1"), 5000, EncryptionRequire)
require.NoError(t, err)
offerData, err := offer.SDP.Marshal()
require.NoError(t, err)

local, err := srtp.DefaultProfiles()
require.NoError(t, err)

answer := func(offerData []byte, opts []srtp.Profile) (sdp.SessionDescription, *MediaConfig) {
t.Helper()
// Re-parse the offer each time: a re-INVITE arrives on the wire, not as the same object.
off, err := ParseOfferWith(g, offerData)
require.NoError(t, err)
answer, mc, err := off.Answer(ip, 5001, EncryptionRequire, WithLocalProfiles(opts))
require.NoError(t, err)
require.NotNil(t, mc.Crypto)
return answer.SDP, mc
}

answer1, mc1 := answer(offerData, local)
answer2, mc2 := answer(offerData, local)
answer3, mc3 := answer(offerData, nil)

require.Equal(t, answer1, answer2, "reusing the same profile must re-derive the same answer")
require.NotEqual(t, answer1, answer3, "using different profiles must result in different answers")
require.Equal(t, mc1.Crypto.Keys.RemoteMasterKey, mc2.Crypto.Keys.RemoteMasterKey)
require.Equal(t, mc1.Crypto.Keys.RemoteMasterSalt, mc2.Crypto.Keys.RemoteMasterSalt)
require.Equal(t, mc1.Crypto.Keys.RemoteMasterKey, mc3.Crypto.Keys.RemoteMasterKey)
require.Equal(t, mc1.Crypto.Keys.RemoteMasterSalt, mc3.Crypto.Keys.RemoteMasterSalt)
require.Equal(t, mc1.Crypto.Keys.LocalMasterKey, mc2.Crypto.Keys.LocalMasterKey)
require.Equal(t, mc1.Crypto.Keys.LocalMasterSalt, mc2.Crypto.Keys.LocalMasterSalt)
require.NotEqual(t, mc1.Crypto.Keys.LocalMasterKey, mc3.Crypto.Keys.LocalMasterKey)
require.NotEqual(t, mc1.Crypto.Keys.LocalMasterSalt, mc3.Crypto.Keys.LocalMasterSalt)

// The a=crypto we send must carry the key we configured locally.
audio := GetAudio(&answer1)
require.NotNil(t, audio)
i := slices.IndexFunc(audio.Attributes, func(a sdp.Attribute) bool { return a.Key == "crypto" })
require.True(t, i >= 0, "no crypto attribute in answer")
inline, err := base64.StdEncoding.DecodeString(strings.TrimSpace(getInline(audio.Attributes[i].Value)))
require.NoError(t, err)
require.Equal(t, append(slices.Clone(mc1.Crypto.Keys.LocalMasterKey), mc1.Crypto.Keys.LocalMasterSalt...), inline)
}

func cryptoAttrs(t testing.TB, s *sdp.SessionDescription) []string {
t.Helper()
audio := GetAudio(s)
require.NotNil(t, audio)
var out []string
for _, a := range audio.Attributes {
if a.Key == "crypto" {
out = append(out, a.Value)
}
}
require.NotEmpty(t, out)
return out
}

// NewOfferWithOpts must advertise exactly the local key material it was given, so that a
// re-offer keeps the keys of a running session instead of re-keying the peer.
func TestNewOfferWithLocalProfiles(t *testing.T) {
g := codecSet()
ip := netip.MustParseAddr("1.1.1.1")

local, err := srtp.DefaultProfiles()
require.NoError(t, err)

offer := func(profiles []srtp.Profile) *Offer {
t.Helper()
o, err := NewOfferWith(g, ip, 5000, EncryptionRequire, WithLocalProfiles(profiles))
require.NoError(t, err)
return o
}

offer1, offer2, offer3 := offer(local), offer(local), offer(nil)

require.Equal(t, local, offer1.CryptoProfiles)
require.Equal(t, cryptoAttrs(t, &offer1.SDP), cryptoAttrs(t, &offer2.SDP), "reusing the same profiles must offer the same keys")
require.NotEqual(t, cryptoAttrs(t, &offer1.SDP), cryptoAttrs(t, &offer3.SDP), "without options each offer re-keys")

// Full round trip: the offerer's negotiated local key must survive a re-offer, even
// though the answerer picks new keys of its own each time.
negotiate := func(o *Offer) *MediaConfig {
t.Helper()
offerData, err := o.SDP.Marshal()
require.NoError(t, err)
parsed, err := ParseOfferWith(g, offerData)
require.NoError(t, err)
answer, _, err := parsed.Answer(netip.MustParseAddr("2.2.2.2"), 5001, EncryptionRequire)
require.NoError(t, err)
answerData, err := answer.SDP.Marshal()
require.NoError(t, err)
parsedAnswer, err := ParseAnswerWith(g, answerData)
require.NoError(t, err)
mc, err := parsedAnswer.Apply(o, EncryptionRequire)
require.NoError(t, err)
require.NotNil(t, mc.Crypto)
return mc
}

mc1, mc2 := negotiate(offer1), negotiate(offer2)
require.Equal(t, mc1.Crypto.Keys.LocalMasterKey, mc2.Crypto.Keys.LocalMasterKey)
require.Equal(t, mc1.Crypto.Keys.LocalMasterSalt, mc2.Crypto.Keys.LocalMasterSalt)
require.NotEqual(t, mc1.Crypto.Keys.RemoteMasterKey, mc2.Crypto.Keys.RemoteMasterKey)
}
11 changes: 11 additions & 0 deletions srtp/srtp.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,21 @@ func DefaultProfiles() ([]Profile, error) {
return out, nil
}

// Options carries the local SRTP material to use for an offer or an answer.
type Options struct {
// Profiles to advertise as our own. Generated with DefaultProfiles if empty.
// Passing the same set again keeps our master keys stable across re-offers.
Profiles []Profile
}

// LocalProfiles returns the profiles from o, generating a fresh set if o is nil or empty.
func (o *Options) LocalProfiles() ([]Profile, error) {
if o != nil && len(o.Profiles) != 0 {
return o.Profiles, nil
}
return DefaultProfiles()
}

type ProtectionProfile string

func (p ProtectionProfile) Parse() (srtp.ProtectionProfile, error) {
Expand Down
Loading