diff --git a/bundlepolicy.go b/bundlepolicy.go index 13411b08854..d4b3f495977 100644 --- a/bundlepolicy.go +++ b/bundlepolicy.go @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: 2026 The Pion community // SPDX-License-Identifier: MIT +// nolint:dupl,staticcheck package webrtc import ( diff --git a/configuration.go b/configuration.go index 369493bd516..64b100d8ccb 100644 --- a/configuration.go +++ b/configuration.go @@ -56,4 +56,8 @@ type Configuration struct { // AlwaysNegotiateDataChannels specifies whether the application prefers // to always negotiate data channels in the initial SDP offer. AlwaysNegotiateDataChannels bool `json:"alwaysNegotiateDataChannels,omitempty"` + + // RTPHeaderEncryptionPolicy affects whether RTP header extension encryption + // (RFC 9335 Cryptex) is negotiated. + RTPHeaderEncryptionPolicy RTPHeaderEncryptionPolicy `json:"rtpHeaderEncryptionPolicy,omitempty"` } diff --git a/configuration_js.go b/configuration_js.go index 5a88f0d8cc1..dfa97c00792 100644 --- a/configuration_js.go +++ b/configuration_js.go @@ -41,5 +41,9 @@ type Configuration struct { // to always negotiate data channels in the initial SDP offer. AlwaysNegotiateDataChannels bool + // RTPHeaderEncryptionPolicy affects whether RTP header extension encryption + // (RFC 9335 Cryptex) is negotiated. + RTPHeaderEncryptionPolicy RTPHeaderEncryptionPolicy + Certificates []Certificate `json:"certificates,omitempty"` } diff --git a/dtlstransport.go b/dtlstransport.go index fe46daffbe4..08a60fdc276 100644 --- a/dtlstransport.go +++ b/dtlstransport.go @@ -43,6 +43,7 @@ type DTLSTransport struct { remoteCertificate []byte state DTLSTransportState srtpProtectionProfile srtp.ProtectionProfile + cryptexMode srtp.CryptexMode onStateChangeHandler func(DTLSTransportState) internalOnCloseHandler func() @@ -192,12 +193,20 @@ func (t *DTLSTransport) GetRemoteCertificate() []byte { return t.remoteCertificate } -func (t *DTLSTransport) startSRTP() error { +// startSRTP requires the caller holds the lock. +func (t *DTLSTransport) startSRTP() error { //nolint:cyclop srtpConfig := &srtp.Config{ Profile: t.srtpProtectionProfile, BufferFactory: t.api.settingEngine.BufferFactory, LoggerFactory: t.api.settingEngine.LoggerFactory, } + + if t.cryptexMode == srtp.CryptexModeEnabled || t.cryptexMode == srtp.CryptexModeRequired { + opt := srtp.Cryptex(t.cryptexMode) + srtpConfig.LocalOptions = append(srtpConfig.LocalOptions, opt) + srtpConfig.RemoteOptions = append(srtpConfig.RemoteOptions, opt) + } + if t.api.settingEngine.replayProtection.SRTP != nil { srtpConfig.RemoteOptions = append( srtpConfig.RemoteOptions, @@ -709,3 +718,26 @@ func (t *DTLSTransport) streamsForSSRC( rtcpInterceptor: rtcpInterceptor, }, nil } + +func (t *DTLSTransport) getCryptexMode() srtp.CryptexMode { + t.lock.RLock() + defer t.lock.RUnlock() + + return t.cryptexMode +} + +func (t *DTLSTransport) setCryptexMode(mode srtp.CryptexMode) { + t.lock.Lock() + defer t.lock.Unlock() + + t.cryptexMode = mode +} + +// rtpHeaderEncryptionNegotiated reports if RFC 9335 RTP Header Extension Encryption ("Cryptex") +// has been negotiated and is enabled for this transceiver. +func (t *DTLSTransport) rtpHeaderEncryptionNegotiated() bool { + t.lock.RLock() + defer t.lock.RUnlock() + + return t.cryptexMode == srtp.CryptexModeEnabled || t.cryptexMode == srtp.CryptexModeRequired +} diff --git a/errors.go b/errors.go index b023d161a22..ef077475f51 100644 --- a/errors.go +++ b/errors.go @@ -53,6 +53,10 @@ var ( // RTCPMuxPolicy was made after PeerConnection has been initialized. ErrModifyingRTCPMuxPolicy = errors.New("rtcp mux policy cannot be modified") + // errModifyingRTPHeaderEncryptionPolicy indicates that an attempt to modify + // RTPHeaderEncryptionPolicy was made after PeerConnection has been initialized. + errModifyingRTPHeaderEncryptionPolicy = errors.New("rtp header encryption policy cannot be modified") + // ErrModifyingICECandidatePoolSize indicates that an attempt to modify // ICECandidatePoolSize was made after PeerConnection has been initialized. ErrModifyingICECandidatePoolSize = errors.New("ice candidate pool size cannot be modified") @@ -135,6 +139,21 @@ var ( // ErrNoSRTPProtectionProfile indicates that the DTLS handshake completed and no SRTP Protection Profile was chosen. ErrNoSRTPProtectionProfile = errors.New("DTLS Handshake completed and no SRTP Protection Profile was chosen") + // ErrRTPHeaderEncryptionRequired indicates that the local endpoint requires + // RFC 9335 Cryptex negotiation, but the remote SDP did not negotiate it. + ErrRTPHeaderEncryptionRequired = errors.New("rtp header extension encryption required") + + // ErrRTPHeaderEncryptionModeChangeNotSupported indicates that the remote SDP attempted + // to change the negotiated RTP header extension encryption mode after SRTP has started. + // Pion's SRTP sessions cannot currently switch Cryptex mode without a full restart. + ErrRTPHeaderEncryptionModeChangeNotSupported = errors.New( + "rtp header extension encryption mode change not supported") + + // ErrRTPHeaderEncryptionMixedModeNotSupported indicates that the remote SDP negotiated + // RTP header extension encryption for only some RTP media sections. Pion currently + // configures Cryptex as a connection-wide SRTP behavior and cannot support mixed mode. + ErrRTPHeaderEncryptionMixedModeNotSupported = errors.New("rtp header extension encryption mixed mode not supported") + // ErrFailedToGenerateCertificateFingerprint indicates that we failed to generate the fingerprint // used for comparing certificates. ErrFailedToGenerateCertificateFingerprint = errors.New("failed to generate certificate fingerprint") diff --git a/peerconnection.go b/peerconnection.go index 105e046c5a3..302e52cdc62 100644 --- a/peerconnection.go +++ b/peerconnection.go @@ -121,12 +121,13 @@ func (api *API) NewPeerConnection(configuration Configuration) (*PeerConnection, pc := &PeerConnection{ id: fmt.Sprintf("PeerConnection-%d", time.Now().UnixNano()), configuration: Configuration{ - ICEServers: []ICEServer{}, - ICETransportPolicy: ICETransportPolicyAll, - BundlePolicy: BundlePolicyBalanced, - RTCPMuxPolicy: RTCPMuxPolicyRequire, - Certificates: []Certificate{}, - ICECandidatePoolSize: 0, + ICEServers: []ICEServer{}, + ICETransportPolicy: ICETransportPolicyAll, + BundlePolicy: BundlePolicyBalanced, + RTCPMuxPolicy: RTCPMuxPolicyRequire, + Certificates: []Certificate{}, + ICECandidatePoolSize: 0, + RTPHeaderEncryptionPolicy: RTPHeaderEncryptionPolicyNegotiate, }, isClosed: &atomic.Bool{}, isCloseDone: make(chan struct{}), @@ -273,6 +274,10 @@ func (pc *PeerConnection) initConfiguration(configuration Configuration) error { pc.configuration.ICEServers = sanitizedICEServers } + if configuration.RTPHeaderEncryptionPolicy != RTPHeaderEncryptionPolicyUnknown { + pc.configuration.RTPHeaderEncryptionPolicy = configuration.RTPHeaderEncryptionPolicy + } + return nil } @@ -626,6 +631,13 @@ func (pc *PeerConnection) SetConfiguration(configuration Configuration) error { pc.configuration.ICEServers = configuration.ICEServers + if configuration.RTPHeaderEncryptionPolicy != RTPHeaderEncryptionPolicyUnknown { + if configuration.RTPHeaderEncryptionPolicy != pc.configuration.RTPHeaderEncryptionPolicy { + return &rtcerr.InvalidModificationError{Err: errModifyingRTPHeaderEncryptionPolicy} + } + pc.configuration.RTPHeaderEncryptionPolicy = configuration.RTPHeaderEncryptionPolicy + } + return nil } @@ -1191,7 +1203,22 @@ func (pc *PeerConnection) SetRemoteDescription(desc SessionDescription) error { default: pc.canTrickleICECandidates = ICETrickleCapabilityUnknown } + + cryptexMode, cryptexErr := pc.getCryptexModeFromRemoteSDP(desc.parsed) pc.mu.Unlock() + if cryptexErr != nil { + return cryptexErr + } + + // If SRTP is already established, we can't safely switch Cryptex mode without + // recreating SRTP sessions (which is not supported by pion/srtp today). + if isRenegotiation { + if srtpSession, err := pc.dtlsTransport.getSRTPSession(); err == nil && srtpSession != nil { + if cryptexMode != pc.dtlsTransport.getCryptexMode() { + return &rtcerr.InvalidModificationError{Err: ErrRTPHeaderEncryptionModeChangeNotSupported} + } + } + } // Disable RTX/FEC on RTPSenders if the remote didn't support it for _, sender := range pc.GetSenders() { @@ -1359,6 +1386,7 @@ func (pc *PeerConnection) SetRemoteDescription(desc SessionDescription) error { iceDetails.Password, fingerprint, fingerprintHash, + cryptexMode, ) if weOffer { pc.startRTP(false, &desc, currentTransceivers) @@ -1368,6 +1396,100 @@ func (pc *PeerConnection) SetRemoteDescription(desc SessionDescription) error { return nil } +func cryptexNegotiatedInSDP(desc *sdp.SessionDescription) (negotiatedForAnyMedia, negotiatedForAllMedia bool) { + negotiatedForAnyMedia = false + negotiatedForAllMedia = true + haveAnyMedia := false + haveRTPMedia := false + + if _, hasCryptex := desc.Attribute(sdp.AttrKeyCryptex); hasCryptex { + return true, true + } + + for _, media := range desc.MediaDescriptions { + if media.MediaName.Port.Value == 0 { + continue + } + + haveAnyMedia = true + if media.MediaName.Media == mediaSectionApplication { + continue + } + haveRTPMedia = true + + hasCryptex := isCryptexSet(media) + negotiatedForAnyMedia = negotiatedForAnyMedia || hasCryptex + negotiatedForAllMedia = negotiatedForAllMedia && hasCryptex + } + + if !haveAnyMedia { + return false, false + } + if !haveRTPMedia { + // If the SDP contains only non-RTP media sections (for example, m=application + // for data channels), Cryptex is not applicable. Treat "negotiated for all RTP + // media" as true (vacuously) so RTPHeaderEncryptionPolicyRequire doesn't reject + // data-only SDP. + return false, true + } + + return negotiatedForAnyMedia, negotiatedForAllMedia +} + +// getCryptexModeFromRemoteSDP requires the caller holds pc.mu. +// +// nolint:cyclop +func (pc *PeerConnection) getCryptexModeFromRemoteSDP(parsed *sdp.SessionDescription) (srtp.CryptexMode, error) { + negotiatedForAnyMedia, negotiatedForAllMedia := cryptexNegotiatedInSDP(parsed) + var mode srtp.CryptexMode + + // When the SDP contains no active RTP media (for example, an initial datachannel-only + // negotiation), we still need to select a stable Cryptex mode for the DTLS transport. + // Otherwise, when RTP m-lines are added later via renegotiation, we would attempt to + // change Cryptex mode after SRTP has already been created. + noActiveRTPMedia := !negotiatedForAnyMedia && negotiatedForAllMedia + if noActiveRTPMedia { + switch pc.configuration.RTPHeaderEncryptionPolicy { + case RTPHeaderEncryptionPolicyDisable: + return srtp.CryptexModeDisabled, nil + case RTPHeaderEncryptionPolicyRequire: + return srtp.CryptexModeRequired, nil + case RTPHeaderEncryptionPolicyUnknown, RTPHeaderEncryptionPolicyNegotiate: + return srtp.CryptexModeEnabled, nil + } + } + + // We can't support mixed Cryptex negotiation across RTP m-lines. + // Accept only: (a) session-level Cryptex, or (b) media-level Cryptex on all RTP m-lines. + if pc.configuration.RTPHeaderEncryptionPolicy != RTPHeaderEncryptionPolicyDisable && + negotiatedForAnyMedia && !negotiatedForAllMedia { + return srtp.CryptexModeDisabled, &rtcerr.InvalidAccessError{Err: ErrRTPHeaderEncryptionMixedModeNotSupported} + } + + switch pc.configuration.RTPHeaderEncryptionPolicy { + case RTPHeaderEncryptionPolicyDisable: + mode = srtp.CryptexModeDisabled + case RTPHeaderEncryptionPolicyUnknown, RTPHeaderEncryptionPolicyNegotiate: + if negotiatedForAnyMedia { + mode = srtp.CryptexModeEnabled + } else { + mode = srtp.CryptexModeDisabled + } + case RTPHeaderEncryptionPolicyRequire: + switch { + case !negotiatedForAllMedia: + return srtp.CryptexModeDisabled, &rtcerr.InvalidAccessError{Err: ErrRTPHeaderEncryptionRequired} + case negotiatedForAnyMedia: + mode = srtp.CryptexModeRequired + default: + // This is case for data-only SDP with no session-level Cryptex attribute. + mode = srtp.CryptexModeDisabled + } + } + + return mode, nil +} + func (pc *PeerConnection) configureReceiver(incoming trackDetails, receiver *RTPReceiver) { receiver.configureReceive(trackDetailsToRTPReceiveParameters(&incoming)) @@ -2769,6 +2891,7 @@ func (pc *PeerConnection) startTransports( iceRole ICERole, dtlsRole DTLSRole, remoteUfrag, remotePwd, fingerprint, fingerprintHash string, + cryptexMode srtp.CryptexMode, ) { // Start the ice transport err := pc.iceTransport.Start( @@ -2799,6 +2922,8 @@ func (pc *PeerConnection) startTransports( }() } + pc.dtlsTransport.setCryptexMode(cryptexMode) + // Start the dtls transport err = pc.dtlsTransport.Start(DTLSParameters{ Role: dtlsRole, @@ -2853,8 +2978,9 @@ func (pc *PeerConnection) generateUnmatchedSDP( } isPlanB := pc.configuration.SDPSemantics == SDPSemanticsPlanB - mediaSections := []mediaSection{} + cryptexEnabledByPolicy := pc.configuration.RTPHeaderEncryptionPolicy != RTPHeaderEncryptionPolicyDisable + mediaSections := []mediaSection{} // Needed for pc.sctpTransport.dataChannelsRequested pc.sctpTransport.lock.Lock() defer pc.sctpTransport.lock.Unlock() @@ -2864,10 +2990,13 @@ func (pc *PeerConnection) generateUnmatchedSDP( audio := make([]*RTPTransceiver, 0) for _, t := range transceivers { - if t.kind == RTPCodecTypeVideo { + switch t.kind { + case RTPCodecTypeVideo: video = append(video, t) - } else if t.kind == RTPCodecTypeAudio { + case RTPCodecTypeAudio: audio = append(audio, t) + case RTPCodecTypeUnknown: + // nothing to do } if sender := t.Sender(); sender != nil { sender.setNegotiated() @@ -2875,25 +3004,43 @@ func (pc *PeerConnection) generateUnmatchedSDP( } if len(video) > 0 { - mediaSections = append(mediaSections, mediaSection{id: "video", transceivers: video}) + mediaSections = append(mediaSections, mediaSection{ + id: "video", + transceivers: video, + cryptex: false, + }) } if len(audio) > 0 { - mediaSections = append(mediaSections, mediaSection{id: "audio", transceivers: audio}) + mediaSections = append(mediaSections, mediaSection{ + id: "audio", + transceivers: audio, + cryptex: false, + }) } if pc.configuration.AlwaysNegotiateDataChannels || pc.sctpTransport.dataChannelsRequested != 0 { - mediaSections = append(mediaSections, mediaSection{id: "data", data: true}) + mediaSections = append(mediaSections, mediaSection{ + id: "data", + data: true, + }) } } else { for _, t := range transceivers { if sender := t.Sender(); sender != nil { sender.setNegotiated() } - mediaSections = append(mediaSections, mediaSection{id: t.Mid(), transceivers: []*RTPTransceiver{t}}) + mediaSections = append(mediaSections, mediaSection{ + id: t.Mid(), + transceivers: []*RTPTransceiver{t}, + cryptex: false, + }) } if pc.configuration.AlwaysNegotiateDataChannels || pc.sctpTransport.dataChannelsRequested != 0 { - mediaSections = append(mediaSections, mediaSection{id: strconv.Itoa(len(mediaSections)), data: true}) + mediaSections = append(mediaSections, mediaSection{ + id: strconv.Itoa(len(mediaSections)), + data: true, + }) } } @@ -2918,13 +3065,14 @@ func (pc *PeerConnection) generateUnmatchedSDP( nil, pc.api.settingEngine.getSCTPMaxMessageSize(), false, + cryptexEnabledByPolicy, ) } // generateMatchedSDP generates a SDP and takes the remote state into account // this is used everytime we have a RemoteDescription // -//nolint:gocognit,gocyclo,cyclop +//nolint:gocognit,gocyclo,cyclop,maintidx func (pc *PeerConnection) generateMatchedSDP( transceivers []*RTPTransceiver, useIdentity, includeUnmatched bool, @@ -2955,6 +3103,22 @@ func (pc *PeerConnection) generateMatchedSDP( isExtmapAllowMixed := isExtMapAllowMixedSet(remoteDescription.parsed) localTransceivers := append([]*RTPTransceiver{}, transceivers...) + cryptexEnabledByPolicy := pc.configuration.RTPHeaderEncryptionPolicy != RTPHeaderEncryptionPolicyDisable + cryptexAtSessionLevel := false + cryptexAtMediaLevel := false + if cryptexEnabledByPolicy { + if includeUnmatched { + // Offers are generated with session-level Cryptex to avoid mixed per-media negotiation. + cryptexAtSessionLevel = true + } else { + // Answers must mirror the attribute level (session vs media) used by the remote offer. + negotiatedForAnyMedia, _ := cryptexNegotiatedInSDP(remoteDescription.parsed) + _, offerHasSessionCryptex := remoteDescription.parsed.Attribute(sdp.AttrKeyCryptex) + cryptexAtSessionLevel = negotiatedForAnyMedia && offerHasSessionCryptex + cryptexAtMediaLevel = negotiatedForAnyMedia && !offerHasSessionCryptex + } + } + detectedPlanB := descriptionIsPlanB(remoteDescription, pc.log) if pc.configuration.SDPSemantics != SDPSemanticsUnifiedPlan { detectedPlanB = descriptionPossiblyPlanB(remoteDescription) @@ -2969,7 +3133,10 @@ func (pc *PeerConnection) generateMatchedSDP( } if media.MediaName.Media == mediaSectionApplication { - mediaSections = append(mediaSections, mediaSection{id: midValue, data: true}) + mediaSections = append(mediaSections, mediaSection{ + id: midValue, + data: true, + }) alreadyHaveApplicationMediaSection = true continue @@ -3010,7 +3177,11 @@ func (pc *PeerConnection) generateMatchedSDP( } mediaTransceivers = append(mediaTransceivers, transceiver) } - mediaSections = append(mediaSections, mediaSection{id: midValue, transceivers: mediaTransceivers}) + mediaSections = append(mediaSections, mediaSection{ + id: midValue, + transceivers: mediaTransceivers, + cryptex: cryptexAtMediaLevel, + }) case sdpSemantics == SDPSemanticsUnifiedPlan || sdpSemantics == SDPSemanticsUnifiedPlanWithFallback: if detectedPlanB { return nil, &rtcerr.TypeError{ @@ -3032,7 +3203,13 @@ func (pc *PeerConnection) generateMatchedSDP( extensions, _ := rtpExtensionsFromMediaDescription(media) mediaSections = append( mediaSections, - mediaSection{id: midValue, transceivers: mediaTransceivers, matchExtensions: extensions, rids: getRids(media)}, + mediaSection{ + id: midValue, + transceivers: mediaTransceivers, + matchExtensions: extensions, + rids: getRids(media), + cryptex: cryptexAtMediaLevel, + }, ) } } @@ -3048,16 +3225,26 @@ func (pc *PeerConnection) generateMatchedSDP( if sender := t.Sender(); sender != nil { sender.setNegotiated() } - mediaSections = append(mediaSections, mediaSection{id: t.Mid(), transceivers: []*RTPTransceiver{t}}) + mediaSections = append(mediaSections, mediaSection{ + id: t.Mid(), + transceivers: []*RTPTransceiver{t}, + cryptex: false, + }) } } if (pc.configuration.AlwaysNegotiateDataChannels || pc.sctpTransport.dataChannelsRequested != 0) && !alreadyHaveApplicationMediaSection { if detectedPlanB { - mediaSections = append(mediaSections, mediaSection{id: "data", data: true}) + mediaSections = append(mediaSections, mediaSection{ + id: "data", + data: true, + }) } else { - mediaSections = append(mediaSections, mediaSection{id: strconv.Itoa(len(mediaSections)), data: true}) + mediaSections = append(mediaSections, mediaSection{ + id: strconv.Itoa(len(mediaSections)), + data: true, + }) } } } else if remoteDescription != nil { @@ -3091,6 +3278,7 @@ func (pc *PeerConnection) generateMatchedSDP( bundleGroup, pc.api.settingEngine.getSCTPMaxMessageSize(), ignoreRidPauseForRecv, + cryptexAtSessionLevel, ) } diff --git a/peerconnection_go_test.go b/peerconnection_go_test.go index bea3afba1fd..ea34c57c895 100644 --- a/peerconnection_go_test.go +++ b/peerconnection_go_test.go @@ -29,6 +29,8 @@ import ( "github.com/pion/logging" "github.com/pion/rtcp" "github.com/pion/rtp" + "github.com/pion/sdp/v3" + "github.com/pion/srtp/v3" "github.com/pion/transport/v4/test" "github.com/pion/transport/v4/vnet" "github.com/pion/turn/v4" @@ -54,6 +56,66 @@ func (api *API) newPair(cfg Configuration) (pcOffer *PeerConnection, pcAnswer *P return pca, pcb, nil } +func waitForSRTPReady(t *testing.T, pc *PeerConnection) { + t.Helper() + + timer := time.NewTimer(20 * time.Second) + defer timer.Stop() + + select { + case <-pc.dtlsTransport.srtpReady: + return + case <-timer.C: + assert.FailNow(t, "timed out waiting for SRTP startup") + } +} + +func hasCryptexForMedia(t *testing.T, sdpStr string, mediaKind string) bool { + t.Helper() + + parsed := &sdp.SessionDescription{} + require.NoError(t, parsed.Unmarshal([]byte(sdpStr))) + + for _, md := range parsed.MediaDescriptions { + if md.MediaName.Media != mediaKind { + continue + } + + return isCryptexSet(md) + } + + assert.FailNowf(t, "media kind %s not found in SDP", mediaKind) + + return false +} + +func hasCryptexForSession(t *testing.T, sdpStr string) bool { + t.Helper() + + parsed := &sdp.SessionDescription{} + require.NoError(t, parsed.Unmarshal([]byte(sdpStr))) + + if _, hasCryptex := parsed.Attribute(sdp.AttrKeyCryptex); hasCryptex { + return true + } + + return false +} + +func dtlsCryptexMode(pc *PeerConnection) srtp.CryptexMode { + return pc.dtlsTransport.getCryptexMode() +} + +func findTransceiverByKind(pc *PeerConnection, kind RTPCodecType) *RTPTransceiver { + for _, tr := range pc.GetTransceivers() { + if tr != nil && tr.Kind() == kind { + return tr + } + } + + return nil +} + func TestNew_Go(t *testing.T) { report := test.CheckRoutines(t) defer report() @@ -285,6 +347,16 @@ func TestPeerConnection_SetConfiguration_Go(t *testing.T) { }, wantErr: &rtcerr.InvalidAccessError{Err: ErrNoTurnCredentials}, }, + { + name: "update RTPHeaderEncryptionPolicy", + init: func() (*PeerConnection, error) { + return NewPeerConnection(Configuration{}) + }, + config: Configuration{ + RTPHeaderEncryptionPolicy: RTPHeaderEncryptionPolicyRequire, + }, + wantErr: &rtcerr.InvalidModificationError{Err: errModifyingRTPHeaderEncryptionPolicy}, + }, } { pc, err := test.init() assert.NoErrorf(t, err, "SetConfiguration %q: init failed", test.name) @@ -304,6 +376,7 @@ func TestPeerConnection_GetConfiguration_Go(t *testing.T) { cfg := pc.GetConfiguration() assert.Equal(t, false, cfg.AlwaysNegotiateDataChannels) + assert.Equal(t, RTPHeaderEncryptionPolicyNegotiate, cfg.RTPHeaderEncryptionPolicy) assert.NoError(t, pc.Close()) } @@ -2696,3 +2769,750 @@ func TestNoDuplicatedAttributesInMediaDescriptions(t *testing.T) { //nolint:cycl } } } + +func TestCryptexNegotiatedInSDP(t *testing.T) { + var desc sdp.SessionDescription + + // Empty SDP + desc = sdp.SessionDescription{} + negotiatedForAnyMedia, negotiatedForAllMedia := cryptexNegotiatedInSDP(&desc) + assert.False(t, negotiatedForAnyMedia) + assert.False(t, negotiatedForAllMedia) + + // Cryptex in session-level attribute + desc = sdp.SessionDescription{} + desc.WithPropertyAttribute(sdp.AttrKeyCryptex) + negotiatedForAnyMedia, negotiatedForAllMedia = cryptexNegotiatedInSDP(&desc) + assert.True(t, negotiatedForAnyMedia) + assert.True(t, negotiatedForAllMedia) + + // Cryptex in media-level attribute in all medias + desc = sdp.SessionDescription{} + media := &sdp.MediaDescription{ + MediaName: sdp.MediaName{ + Media: "audio", + Port: sdp.RangedPort{Value: 9}, + }, + } + media.WithPropertyAttribute(sdp.AttrKeyCryptex) + desc.WithMedia(media) + media2 := &sdp.MediaDescription{ + MediaName: sdp.MediaName{ + Media: "video", + Port: sdp.RangedPort{Value: 9}, + }, + } + media2.WithPropertyAttribute(sdp.AttrKeyCryptex) + desc.WithMedia(media2) + negotiatedForAnyMedia, negotiatedForAllMedia = cryptexNegotiatedInSDP(&desc) + assert.True(t, negotiatedForAnyMedia) + assert.True(t, negotiatedForAllMedia) + + // Cryptex in media-level attribute in some medias + desc = sdp.SessionDescription{} + media = &sdp.MediaDescription{ + MediaName: sdp.MediaName{ + Media: "audio", + Port: sdp.RangedPort{Value: 9}, + }, + } + media.WithPropertyAttribute(sdp.AttrKeyCryptex) + desc.WithMedia(media) + media2 = &sdp.MediaDescription{ + MediaName: sdp.MediaName{ + Media: "video", + Port: sdp.RangedPort{Value: 9}, + }, + } + desc.WithMedia(media2) + negotiatedForAnyMedia, negotiatedForAllMedia = cryptexNegotiatedInSDP(&desc) + assert.True(t, negotiatedForAnyMedia) + assert.False(t, negotiatedForAllMedia) + + // Ignore application section + desc = sdp.SessionDescription{} + media = &sdp.MediaDescription{ + MediaName: sdp.MediaName{ + Media: "audio", + Port: sdp.RangedPort{Value: 9}, + }, + } + media.WithPropertyAttribute(sdp.AttrKeyCryptex) + desc.WithMedia(media) + media2 = &sdp.MediaDescription{ + MediaName: sdp.MediaName{ + Media: "application", + Port: sdp.RangedPort{Value: 9}, + }, + } + desc.WithMedia(media2) + negotiatedForAnyMedia, negotiatedForAllMedia = cryptexNegotiatedInSDP(&desc) + assert.True(t, negotiatedForAnyMedia) + assert.True(t, negotiatedForAllMedia) + + // Application only (no RTP m-lines) + desc = sdp.SessionDescription{} + media = &sdp.MediaDescription{ + MediaName: sdp.MediaName{ + Media: "application", + Port: sdp.RangedPort{Value: 9}, + }, + } + desc.WithMedia(media) + negotiatedForAnyMedia, negotiatedForAllMedia = cryptexNegotiatedInSDP(&desc) + assert.False(t, negotiatedForAnyMedia) + assert.True(t, negotiatedForAllMedia) + + // Ignore medias with port 0 + desc = sdp.SessionDescription{} + media = &sdp.MediaDescription{ + MediaName: sdp.MediaName{ + Media: "audio", + Port: sdp.RangedPort{Value: 9}, + }, + } + media.WithPropertyAttribute(sdp.AttrKeyCryptex) + desc.WithMedia(media) + media2 = &sdp.MediaDescription{ + MediaName: sdp.MediaName{ + Media: "video", + Port: sdp.RangedPort{Value: 0}, + }, + } + desc.WithMedia(media2) + negotiatedForAnyMedia, negotiatedForAllMedia = cryptexNegotiatedInSDP(&desc) + assert.True(t, negotiatedForAnyMedia) + assert.True(t, negotiatedForAllMedia) + + // No cryptex anywhere + desc = sdp.SessionDescription{} + media = &sdp.MediaDescription{ + MediaName: sdp.MediaName{ + Media: "audio", + Port: sdp.RangedPort{Value: 9}, + }, + } + desc.WithMedia(media) + negotiatedForAnyMedia, negotiatedForAllMedia = cryptexNegotiatedInSDP(&desc) + assert.False(t, negotiatedForAnyMedia) + assert.False(t, negotiatedForAllMedia) +} + +func TestCryptexAttributeInOffer(t *testing.T) { + // Check if Cryptex attribute is correctly added to offer SDP based on RtpHeaderEncryptionPolicy + // and SettingEngine.DisableRTPHeaderEncryption settings. + + tests := []RTPHeaderEncryptionPolicy{ + RTPHeaderEncryptionPolicyUnknown, + RTPHeaderEncryptionPolicyDisable, + RTPHeaderEncryptionPolicyNegotiate, + RTPHeaderEncryptionPolicyRequire, + } + for _, policy := range tests { + t.Run(policy.String(), func(t *testing.T) { + pcOffer, err := NewPeerConnection(Configuration{ + RTPHeaderEncryptionPolicy: policy, + }) + require.NoError(t, err) + defer func() { require.NoError(t, pcOffer.Close()) }() + + _, err = pcOffer.AddTransceiverFromKind(RTPCodecTypeVideo) + require.NoError(t, err) + _, err = pcOffer.AddTransceiverFromKind(RTPCodecTypeAudio) + require.NoError(t, err) + _, err = pcOffer.CreateDataChannel("initial_data_channel", nil) + require.NoError(t, err) + + offer, err := pcOffer.CreateOffer(nil) + require.NoError(t, err) + + hasVideoCryptex := hasCryptexForMedia(t, offer.SDP, string(MediaKindVideo)) + hasAudioCryptex := hasCryptexForMedia(t, offer.SDP, string(MediaKindAudio)) + hasApplicationCryptex := hasCryptexForMedia(t, offer.SDP, mediaSectionApplication) + hasSessionCryptex := hasCryptexForSession(t, offer.SDP) + switch policy { + case RTPHeaderEncryptionPolicyDisable: + assert.False(t, hasSessionCryptex, "unexpected session-level a=cryptex") + assert.False(t, hasVideoCryptex, "unexpected a=cryptex on offer video m-line") + assert.False(t, hasAudioCryptex, "unexpected a=cryptex on offer audio m-line") + assert.False(t, hasApplicationCryptex, "unexpected a=cryptex on offer application m-line") + case RTPHeaderEncryptionPolicyUnknown, RTPHeaderEncryptionPolicyNegotiate, RTPHeaderEncryptionPolicyRequire: + assert.True(t, hasSessionCryptex, "expected session-level a=cryptex") + assert.False(t, hasVideoCryptex, "unexpected a=cryptex on offer video m-line") + assert.False(t, hasAudioCryptex, "unexpected a=cryptex on offer audio m-line") + assert.False(t, hasApplicationCryptex, "unexpected a=cryptex on offer application m-line") + default: + assert.FailNow(t, "Should not happen") + } + }) + } +} + +//nolint:cyclop,dupl +func TestCryptexAttributeInAnswerForOfferWithCryptex(t *testing.T) { + // Check if Cryptex attribute is correctly added to answer SDP based on RtpHeaderEncryptionPolicy + // and SettingEngine.DisableRTPHeaderEncryption settings when offer contains Cryptex attribute. + // Also check that answer is accepted. + + tests := []RTPHeaderEncryptionPolicy{ + RTPHeaderEncryptionPolicyUnknown, + RTPHeaderEncryptionPolicyDisable, + RTPHeaderEncryptionPolicyNegotiate, + RTPHeaderEncryptionPolicyRequire, + } + for _, policy := range tests { + t.Run(policy.String(), func(t *testing.T) { + pcOffer, err := NewPeerConnection(Configuration{ + RTPHeaderEncryptionPolicy: RTPHeaderEncryptionPolicyNegotiate, + }) + require.NoError(t, err) + + pcAnswer, err := NewPeerConnection(Configuration{ + RTPHeaderEncryptionPolicy: policy, + }) + require.NoError(t, err) + defer closePairNow(t, pcOffer, pcAnswer) + + offerVideoTr, err := pcOffer.AddTransceiverFromKind(RTPCodecTypeVideo) + require.NoError(t, err) + offerAudioTr, err := pcOffer.AddTransceiverFromKind(RTPCodecTypeAudio) + require.NoError(t, err) + _, err = pcOffer.CreateDataChannel("initial_data_channel", nil) + require.NoError(t, err) + + offer, err := pcOffer.CreateOffer(nil) + require.NoError(t, err) + offerGatheringComplete := GatheringCompletePromise(pcOffer) + require.NoError(t, pcOffer.SetLocalDescription(offer)) + <-offerGatheringComplete + offer = *pcOffer.LocalDescription() + + require.NoError(t, pcAnswer.SetRemoteDescription(offer)) + + answer, err := pcAnswer.CreateAnswer(nil) + require.NoError(t, err) + answerGatheringComplete := GatheringCompletePromise(pcAnswer) + require.NoError(t, pcAnswer.SetLocalDescription(answer)) + <-answerGatheringComplete + answer = *pcAnswer.LocalDescription() + + hasVideoCryptex := hasCryptexForMedia(t, answer.SDP, string(MediaKindVideo)) + hasAudioCryptex := hasCryptexForMedia(t, answer.SDP, string(MediaKindAudio)) + hasApplicationCryptex := hasCryptexForMedia(t, answer.SDP, mediaSectionApplication) + hasSessionCryptex := hasCryptexForSession(t, answer.SDP) + switch policy { + case RTPHeaderEncryptionPolicyDisable: + assert.False(t, hasSessionCryptex, "unexpected session-level a=cryptex") + assert.False(t, hasVideoCryptex, "unexpected a=cryptex on offer video m-line") + assert.False(t, hasAudioCryptex, "unexpected a=cryptex on offer audio m-line") + assert.False(t, hasApplicationCryptex, "unexpected a=cryptex on offer application m-line") + case RTPHeaderEncryptionPolicyUnknown, RTPHeaderEncryptionPolicyNegotiate, RTPHeaderEncryptionPolicyRequire: + assert.True(t, hasSessionCryptex, "expected session-level a=cryptex") + assert.False(t, hasVideoCryptex, "unexpected a=cryptex on offer video m-line") + assert.False(t, hasAudioCryptex, "unexpected a=cryptex on offer audio m-line") + assert.False(t, hasApplicationCryptex, "unexpected a=cryptex on offer application m-line") + default: + assert.FailNow(t, "Should not happen") + } + + require.NoError(t, pcOffer.SetRemoteDescription(answer)) + + waitForSRTPReady(t, pcAnswer) + waitForSRTPReady(t, pcOffer) + + answerVideoTr := findTransceiverByKind(pcAnswer, RTPCodecTypeVideo) + answerAudioTr := findTransceiverByKind(pcAnswer, RTPCodecTypeAudio) + require.NotNil(t, answerVideoTr) + require.NotNil(t, answerAudioTr) + + if policy == RTPHeaderEncryptionPolicyDisable { + assert.Equal(t, srtp.CryptexModeDisabled, dtlsCryptexMode(pcOffer)) + assert.Equal(t, srtp.CryptexModeDisabled, dtlsCryptexMode(pcAnswer)) + + assert.False(t, offerVideoTr.RTPHeaderEncryptionNegotiated()) + assert.False(t, offerAudioTr.RTPHeaderEncryptionNegotiated()) + assert.False(t, answerVideoTr.RTPHeaderEncryptionNegotiated()) + assert.False(t, answerAudioTr.RTPHeaderEncryptionNegotiated()) + } else { + assert.Equal(t, srtp.CryptexModeEnabled, dtlsCryptexMode(pcOffer)) + if policy == RTPHeaderEncryptionPolicyRequire { + assert.Equal(t, srtp.CryptexModeRequired, dtlsCryptexMode(pcAnswer)) + } else { + assert.Equal(t, srtp.CryptexModeEnabled, dtlsCryptexMode(pcAnswer)) + } + + assert.True(t, offerVideoTr.RTPHeaderEncryptionNegotiated()) + assert.True(t, offerAudioTr.RTPHeaderEncryptionNegotiated()) + assert.True(t, answerVideoTr.RTPHeaderEncryptionNegotiated()) + assert.True(t, answerAudioTr.RTPHeaderEncryptionNegotiated()) + } + }) + } +} + +func TestCryptexAttributeInAnswerForOfferWithoutCryptex(t *testing.T) { + // Check how answer SDP Cryptex attribute negotiation behaves based on RtpHeaderEncryptionPolicy + // and SettingEngine.DisableRTPHeaderEncryption settings when offer does NOT contain Cryptex attribute. + // Also check that answer is accepted. + + tests := []RTPHeaderEncryptionPolicy{ + RTPHeaderEncryptionPolicyUnknown, + RTPHeaderEncryptionPolicyDisable, + RTPHeaderEncryptionPolicyNegotiate, + RTPHeaderEncryptionPolicyRequire, + } + for _, policy := range tests { + t.Run(policy.String(), func(t *testing.T) { + pcOffer, err := NewPeerConnection(Configuration{ + RTPHeaderEncryptionPolicy: RTPHeaderEncryptionPolicyDisable, + }) + require.NoError(t, err) + + pcAnswer, err := NewPeerConnection(Configuration{ + RTPHeaderEncryptionPolicy: policy, + }) + require.NoError(t, err) + defer closePairNow(t, pcOffer, pcAnswer) + + offerVideoTr, err := pcOffer.AddTransceiverFromKind(RTPCodecTypeVideo) + require.NoError(t, err) + offerAudioTr, err := pcOffer.AddTransceiverFromKind(RTPCodecTypeAudio) + require.NoError(t, err) + _, err = pcOffer.CreateDataChannel("initial_data_channel", nil) + require.NoError(t, err) + + offer, err := pcOffer.CreateOffer(nil) + require.NoError(t, err) + offerGatheringComplete := GatheringCompletePromise(pcOffer) + require.NoError(t, pcOffer.SetLocalDescription(offer)) + <-offerGatheringComplete + offer = *pcOffer.LocalDescription() + + err = pcAnswer.SetRemoteDescription(offer) + switch policy { + case RTPHeaderEncryptionPolicyDisable, RTPHeaderEncryptionPolicyUnknown, RTPHeaderEncryptionPolicyNegotiate: + var answer SessionDescription + answer, err = pcAnswer.CreateAnswer(nil) + require.NoError(t, err) + answerGatheringComplete := GatheringCompletePromise(pcAnswer) + require.NoError(t, pcAnswer.SetLocalDescription(answer)) + <-answerGatheringComplete + answer = *pcAnswer.LocalDescription() + + require.NoError(t, err) + hasVideoCryptex := hasCryptexForMedia(t, answer.SDP, string(MediaKindVideo)) + hasAudioCryptex := hasCryptexForMedia(t, answer.SDP, string(MediaKindAudio)) + hasApplicationCryptex := hasCryptexForMedia(t, answer.SDP, mediaSectionApplication) + + assert.False(t, hasVideoCryptex, "unexpected a=cryptex on offer video m-line") + assert.False(t, hasAudioCryptex, "unexpected a=cryptex on offer audio m-line") + assert.False(t, hasApplicationCryptex, "unexpected a=cryptex on offer application m-line") + + require.NoError(t, pcOffer.SetRemoteDescription(answer)) + + waitForSRTPReady(t, pcAnswer) + waitForSRTPReady(t, pcOffer) + answerVideoTr := findTransceiverByKind(pcAnswer, RTPCodecTypeVideo) + answerAudioTr := findTransceiverByKind(pcAnswer, RTPCodecTypeAudio) + require.NotNil(t, answerVideoTr) + require.NotNil(t, answerAudioTr) + + assert.Equal(t, srtp.CryptexModeDisabled, dtlsCryptexMode(pcOffer)) + assert.Equal(t, srtp.CryptexModeDisabled, dtlsCryptexMode(pcAnswer)) + + assert.False(t, offerVideoTr.RTPHeaderEncryptionNegotiated()) + assert.False(t, offerAudioTr.RTPHeaderEncryptionNegotiated()) + assert.False(t, answerVideoTr.RTPHeaderEncryptionNegotiated()) + assert.False(t, answerAudioTr.RTPHeaderEncryptionNegotiated()) + case RTPHeaderEncryptionPolicyRequire: + require.ErrorIs(t, err, ErrRTPHeaderEncryptionRequired, "expected error for offer without Cryptex") + default: + assert.FailNow(t, "Should not happen") + } + }) + } +} + +func TestCryptexAttributeInAnswerMissingWhenOffererRequiresCryptex(t *testing.T) { + // Check that answer SDP without Cryptex attribute is rejected when offerer requires Cryptex. + + pcOffer, err := NewPeerConnection(Configuration{ + RTPHeaderEncryptionPolicy: RTPHeaderEncryptionPolicyRequire, + }) + require.NoError(t, err) + + pcAnswer, err := NewPeerConnection(Configuration{ + RTPHeaderEncryptionPolicy: RTPHeaderEncryptionPolicyDisable, + }) + require.NoError(t, err) + defer closePairNow(t, pcOffer, pcAnswer) + + _, err = pcOffer.AddTransceiverFromKind(RTPCodecTypeVideo) + require.NoError(t, err) + _, err = pcOffer.AddTransceiverFromKind(RTPCodecTypeAudio) + require.NoError(t, err) + _, err = pcOffer.CreateDataChannel("initial_data_channel", nil) + require.NoError(t, err) + + offer, err := pcOffer.CreateOffer(nil) + require.NoError(t, err) + offerGatheringComplete := GatheringCompletePromise(pcOffer) + require.NoError(t, pcOffer.SetLocalDescription(offer)) + <-offerGatheringComplete + offer = *pcOffer.LocalDescription() + + require.NoError(t, pcAnswer.SetRemoteDescription(offer)) + + answer, err := pcAnswer.CreateAnswer(nil) + require.NoError(t, err) + answerGatheringComplete := GatheringCompletePromise(pcAnswer) + require.NoError(t, pcAnswer.SetLocalDescription(answer)) + <-answerGatheringComplete + answer = *pcAnswer.LocalDescription() + + err = pcOffer.SetRemoteDescription(answer) + require.ErrorIs(t, err, ErrRTPHeaderEncryptionRequired, "expected error for answer without Cryptex") +} + +//nolint:cyclop,dupl +func TestCryptexAttributeInAnswerForMediaLevelCryptex(t *testing.T) { + // Check if Cryptex attribute added at media-level in offer results in correct answer SDP + // based on RtpHeaderEncryptionPolicy and SettingEngine.DisableRTPHeaderEncryption settings. + // Also check that answer is accepted. + + tests := []RTPHeaderEncryptionPolicy{ + RTPHeaderEncryptionPolicyUnknown, + RTPHeaderEncryptionPolicyDisable, + RTPHeaderEncryptionPolicyNegotiate, + RTPHeaderEncryptionPolicyRequire, + } + for _, policy := range tests { + t.Run(policy.String(), func(t *testing.T) { + pcOffer, err := NewPeerConnection(Configuration{ + RTPHeaderEncryptionPolicy: RTPHeaderEncryptionPolicyNegotiate, + }) + require.NoError(t, err) + + pcAnswer, err := NewPeerConnection(Configuration{ + RTPHeaderEncryptionPolicy: policy, + }) + require.NoError(t, err) + defer closePairNow(t, pcOffer, pcAnswer) + + offerVideoTr, err := pcOffer.AddTransceiverFromKind(RTPCodecTypeVideo) + require.NoError(t, err) + offerAudioTr, err := pcOffer.AddTransceiverFromKind(RTPCodecTypeAudio) + require.NoError(t, err) + _, err = pcOffer.CreateDataChannel("initial_data_channel", nil) + require.NoError(t, err) + + offer, err := pcOffer.CreateOffer(nil) + require.NoError(t, err) + offerGatheringComplete := GatheringCompletePromise(pcOffer) + require.NoError(t, pcOffer.SetLocalDescription(offer)) + <-offerGatheringComplete + offer = *pcOffer.LocalDescription() + + // Manually switch offer SDP to media-level Cryptex (and remove session-level Cryptex). + offerSDP := &sdp.SessionDescription{} + require.NoError(t, offerSDP.Unmarshal([]byte(offer.SDP))) + offerSDP.Attributes = slices.DeleteFunc(offerSDP.Attributes, func(a sdp.Attribute) bool { + return a.Key == sdp.AttrKeyCryptex + }) + for _, md := range offerSDP.MediaDescriptions { + if md.MediaName.Port.Value == 0 || md.MediaName.Media == mediaSectionApplication { + continue + } + md.WithPropertyAttribute(sdp.AttrKeyCryptex) + } + offerSDPBytes, err := offerSDP.Marshal() + require.NoError(t, err) + offer.SDP = string(offerSDPBytes) + + require.NoError(t, pcAnswer.SetRemoteDescription(offer)) + + answer, err := pcAnswer.CreateAnswer(nil) + require.NoError(t, err) + answerGatheringComplete := GatheringCompletePromise(pcAnswer) + require.NoError(t, pcAnswer.SetLocalDescription(answer)) + <-answerGatheringComplete + answer = *pcAnswer.LocalDescription() + + hasVideoCryptex := hasCryptexForMedia(t, answer.SDP, string(MediaKindVideo)) + hasAudioCryptex := hasCryptexForMedia(t, answer.SDP, string(MediaKindAudio)) + hasApplicationCryptex := hasCryptexForMedia(t, answer.SDP, mediaSectionApplication) + hasSessionCryptex := hasCryptexForSession(t, answer.SDP) + switch policy { + case RTPHeaderEncryptionPolicyDisable: + assert.False(t, hasSessionCryptex, "unexpected session-level a=cryptex") + assert.False(t, hasVideoCryptex, "unexpected a=cryptex on offer video m-line") + assert.False(t, hasAudioCryptex, "unexpected a=cryptex on offer audio m-line") + assert.False(t, hasApplicationCryptex, "unexpected a=cryptex on offer application m-line") + case RTPHeaderEncryptionPolicyUnknown, RTPHeaderEncryptionPolicyNegotiate, RTPHeaderEncryptionPolicyRequire: + assert.False(t, hasSessionCryptex, "unexpected session-level a=cryptex") + assert.True(t, hasVideoCryptex, "expected a=cryptex on offer video m-line") + assert.True(t, hasAudioCryptex, "expected a=cryptex on offer audio m-line") + assert.False(t, hasApplicationCryptex, "unexpected a=cryptex on offer application m-line") + default: + assert.FailNow(t, "Should not happen") + } + + require.NoError(t, pcOffer.SetRemoteDescription(answer)) + + waitForSRTPReady(t, pcOffer) + waitForSRTPReady(t, pcAnswer) + answerVideoTr := findTransceiverByKind(pcAnswer, RTPCodecTypeVideo) + answerAudioTr := findTransceiverByKind(pcAnswer, RTPCodecTypeAudio) + require.NotNil(t, answerVideoTr) + require.NotNil(t, answerAudioTr) + + if policy == RTPHeaderEncryptionPolicyDisable { + assert.Equal(t, srtp.CryptexModeDisabled, dtlsCryptexMode(pcOffer)) + assert.Equal(t, srtp.CryptexModeDisabled, dtlsCryptexMode(pcAnswer)) + + assert.False(t, offerVideoTr.RTPHeaderEncryptionNegotiated()) + assert.False(t, offerAudioTr.RTPHeaderEncryptionNegotiated()) + assert.False(t, answerVideoTr.RTPHeaderEncryptionNegotiated()) + assert.False(t, answerAudioTr.RTPHeaderEncryptionNegotiated()) + } else { + assert.Equal(t, srtp.CryptexModeEnabled, dtlsCryptexMode(pcOffer)) + if policy == RTPHeaderEncryptionPolicyRequire { + assert.Equal(t, srtp.CryptexModeRequired, dtlsCryptexMode(pcAnswer)) + } else { + assert.Equal(t, srtp.CryptexModeEnabled, dtlsCryptexMode(pcAnswer)) + } + + assert.True(t, offerVideoTr.RTPHeaderEncryptionNegotiated()) + assert.True(t, offerAudioTr.RTPHeaderEncryptionNegotiated()) + assert.True(t, answerVideoTr.RTPHeaderEncryptionNegotiated()) + assert.True(t, answerAudioTr.RTPHeaderEncryptionNegotiated()) + } + }) + } +} + +func TestCryptexAnswerWithPartialMediaCryptexIsRejected(t *testing.T) { + // Check that answer SDP with Cryptex attribute on only some media sections is rejected when offerer requires + // Cryptex. + + pcOffer, err := NewPeerConnection(Configuration{RTPHeaderEncryptionPolicy: RTPHeaderEncryptionPolicyNegotiate}) + require.NoError(t, err) + + pcAnswer, err := NewPeerConnection(Configuration{RTPHeaderEncryptionPolicy: RTPHeaderEncryptionPolicyNegotiate}) + require.NoError(t, err) + defer closePairNow(t, pcOffer, pcAnswer) + + _, err = pcOffer.AddTransceiverFromKind(RTPCodecTypeVideo) + require.NoError(t, err) + _, err = pcOffer.AddTransceiverFromKind(RTPCodecTypeAudio) + require.NoError(t, err) + + offer, err := pcOffer.CreateOffer(nil) + require.NoError(t, err) + offerGatheringComplete := GatheringCompletePromise(pcOffer) + require.NoError(t, pcOffer.SetLocalDescription(offer)) + <-offerGatheringComplete + offer = *pcOffer.LocalDescription() + + require.NoError(t, pcAnswer.SetRemoteDescription(offer)) + + answer, err := pcAnswer.CreateAnswer(nil) + require.NoError(t, err) + answerGatheringComplete := GatheringCompletePromise(pcAnswer) + require.NoError(t, pcAnswer.SetLocalDescription(answer)) + <-answerGatheringComplete + answer = *pcAnswer.LocalDescription() + + // Modify answer SDP to negotiate Cryptex for only one RTP media section. + parsed := &sdp.SessionDescription{} + require.NoError(t, parsed.Unmarshal([]byte(answer.SDP))) + parsed.Attributes = slices.DeleteFunc(parsed.Attributes, func(a sdp.Attribute) bool { + return a.Key == sdp.AttrKeyCryptex + }) + + cryptexSet := false + for _, md := range parsed.MediaDescriptions { + if md.MediaName.Port.Value == 0 || md.MediaName.Media == mediaSectionApplication { + continue + } + kind := NewRTPCodecType(md.MediaName.Media) + if kind != RTPCodecTypeVideo && kind != RTPCodecTypeAudio { + continue + } + md.Attributes = slices.DeleteFunc(md.Attributes, func(a sdp.Attribute) bool { + return a.Key == sdp.AttrKeyCryptex + }) + if !cryptexSet { + md.WithPropertyAttribute(sdp.AttrKeyCryptex) + cryptexSet = true + } + } + require.True(t, cryptexSet) + + marshaled, err := parsed.Marshal() + require.NoError(t, err) + answer.SDP = string(marshaled) + + err = pcOffer.SetRemoteDescription(answer) + require.ErrorIs(t, err, ErrRTPHeaderEncryptionMixedModeNotSupported) +} + +func TestCryptexRequiredDoesNotFailForDataOnlyOffer(t *testing.T) { + // Check that offer with no RTP media sections but with application section is accepted when answerer requires + // Cryptex. + + pcOffer, err := NewPeerConnection(Configuration{RTPHeaderEncryptionPolicy: RTPHeaderEncryptionPolicyDisable}) + require.NoError(t, err) + + pcAnswer, err := NewPeerConnection(Configuration{RTPHeaderEncryptionPolicy: RTPHeaderEncryptionPolicyRequire}) + require.NoError(t, err) + defer closePairNow(t, pcOffer, pcAnswer) + + _, err = pcOffer.CreateDataChannel("data", nil) + require.NoError(t, err) + + offer, err := pcOffer.CreateOffer(nil) + require.NoError(t, err) + offerGatheringComplete := GatheringCompletePromise(pcOffer) + require.NoError(t, pcOffer.SetLocalDescription(offer)) + <-offerGatheringComplete + offer = *pcOffer.LocalDescription() + + // Sanity check: offer contains no RTP m-lines. + require.NotNil(t, offer.parsed) + for _, md := range offer.parsed.MediaDescriptions { + require.Equal(t, mediaSectionApplication, md.MediaName.Media) + } + + // Required mode must not reject data-only SDP. + require.NoError(t, pcAnswer.SetRemoteDescription(offer)) + + answer, err := pcAnswer.CreateAnswer(nil) + require.NoError(t, err) + answerGatheringComplete := GatheringCompletePromise(pcAnswer) + require.NoError(t, pcAnswer.SetLocalDescription(answer)) + <-answerGatheringComplete + answer = *pcAnswer.LocalDescription() + + // Application sections must not carry a=cryptex. + assert.False(t, hasCryptexForMedia(t, answer.SDP, mediaSectionApplication), + "unexpected a=cryptex on answer application m-line") + + require.NoError(t, pcOffer.SetRemoteDescription(answer)) +} + +func TestCryptexIsStillOfferedDuringRenegotiationIfNotNegotiatedInitially(t *testing.T) { + // Check that if Cryptex is offered but not negotiated in initial offer/answer, it is still offered in subsequent + // offers. + + pcOffer, err := NewPeerConnection(Configuration{RTPHeaderEncryptionPolicy: RTPHeaderEncryptionPolicyNegotiate}) + require.NoError(t, err) + + pcAnswer, err := NewPeerConnection(Configuration{RTPHeaderEncryptionPolicy: RTPHeaderEncryptionPolicyDisable}) + require.NoError(t, err) + defer closePairNow(t, pcOffer, pcAnswer) + + _, err = pcOffer.AddTransceiverFromKind(RTPCodecTypeVideo) + require.NoError(t, err) + _, err = pcOffer.AddTransceiverFromKind(RTPCodecTypeAudio) + require.NoError(t, err) + _, err = pcOffer.CreateDataChannel("initial_data_channel", nil) + require.NoError(t, err) + + // Initial negotiation: offer includes Cryptex, answer does not. + offer, err := pcOffer.CreateOffer(nil) + require.NoError(t, err) + offerGatheringComplete := GatheringCompletePromise(pcOffer) + require.NoError(t, pcOffer.SetLocalDescription(offer)) + <-offerGatheringComplete + offer = *pcOffer.LocalDescription() + + require.NoError(t, pcAnswer.SetRemoteDescription(offer)) + + answer, err := pcAnswer.CreateAnswer(nil) + require.NoError(t, err) + answerGatheringComplete := GatheringCompletePromise(pcAnswer) + require.NoError(t, pcAnswer.SetLocalDescription(answer)) + <-answerGatheringComplete + answer = *pcAnswer.LocalDescription() + + require.NoError(t, pcOffer.SetRemoteDescription(answer)) + + // Renegotiation: even though Cryptex wasn't negotiated initially, we should still advertise it. + offer2, err := pcOffer.CreateOffer(nil) + require.NoError(t, err) + + assert.True(t, hasCryptexForSession(t, offer2.SDP), "expected session-level a=cryptex on renegotiation offer") + assert.False(t, hasCryptexForMedia(t, offer2.SDP, string(MediaKindVideo)), + "unexpected a=cryptex on renegotiation offer video m-line") + assert.False(t, hasCryptexForMedia(t, offer2.SDP, string(MediaKindAudio)), + "unexpected a=cryptex on renegotiation offer audio m-line") + assert.False(t, hasCryptexForMedia(t, offer2.SDP, mediaSectionApplication), + "unexpected a=cryptex on renegotiation offer application m-line") +} + +func TestCryptexModeChangeDuringRenegotiationIsRejected(t *testing.T) { + // Check that if Cryptex is offered but not negotiated in initial offer/answer, and then in subsequent offerer + // tries to change Cryptex mode (e.g. by moving it from session-level to media-level), the offer is rejected. + + pcOffer, err := NewPeerConnection(Configuration{RTPHeaderEncryptionPolicy: RTPHeaderEncryptionPolicyNegotiate}) + require.NoError(t, err) + + pcAnswer, err := NewPeerConnection(Configuration{RTPHeaderEncryptionPolicy: RTPHeaderEncryptionPolicyDisable}) + require.NoError(t, err) + defer closePairNow(t, pcOffer, pcAnswer) + + _, err = pcOffer.AddTransceiverFromKind(RTPCodecTypeVideo) + require.NoError(t, err) + _, err = pcOffer.AddTransceiverFromKind(RTPCodecTypeAudio) + require.NoError(t, err) + + // Initial negotiation completes with Cryptex not negotiated (answerer disables it). + offer, err := pcOffer.CreateOffer(nil) + require.NoError(t, err) + offerGatheringComplete := GatheringCompletePromise(pcOffer) + require.NoError(t, pcOffer.SetLocalDescription(offer)) + <-offerGatheringComplete + offer = *pcOffer.LocalDescription() + + require.NoError(t, pcAnswer.SetRemoteDescription(offer)) + + answer, err := pcAnswer.CreateAnswer(nil) + require.NoError(t, err) + answerGatheringComplete := GatheringCompletePromise(pcAnswer) + require.NoError(t, pcAnswer.SetLocalDescription(answer)) + <-answerGatheringComplete + answer = *pcAnswer.LocalDescription() + + require.NoError(t, pcOffer.SetRemoteDescription(answer)) + + waitForSRTPReady(t, pcOffer) + waitForSRTPReady(t, pcAnswer) + require.Equal(t, srtp.CryptexModeDisabled, dtlsCryptexMode(pcOffer)) + + // Renegotiation offer from answerer, but manually inject Cryptex attribute. + offer2, err := pcAnswer.CreateOffer(nil) + require.NoError(t, err) + offer2GatheringComplete := GatheringCompletePromise(pcAnswer) + require.NoError(t, pcAnswer.SetLocalDescription(offer2)) + <-offer2GatheringComplete + offer2 = *pcAnswer.LocalDescription() + + parsed := &sdp.SessionDescription{} + require.NoError(t, parsed.Unmarshal([]byte(offer2.SDP))) + for _, md := range parsed.MediaDescriptions { + if md.MediaName.Port.Value == 0 || md.MediaName.Media == mediaSectionApplication { + continue + } + kind := NewRTPCodecType(md.MediaName.Media) + if kind == RTPCodecTypeAudio || kind == RTPCodecTypeVideo { + md.WithPropertyAttribute(sdp.AttrKeyCryptex) + } + } + marshaled, err := parsed.Marshal() + require.NoError(t, err) + offer2.SDP = string(marshaled) + + err = pcOffer.SetRemoteDescription(offer2) + require.ErrorIs(t, err, ErrRTPHeaderEncryptionModeChangeNotSupported) +} diff --git a/peerconnection_js.go b/peerconnection_js.go index 87460bdda61..61e747fb6b9 100644 --- a/peerconnection_js.go +++ b/peerconnection_js.go @@ -568,6 +568,7 @@ func configurationToValue(configuration Configuration) js.Value { "peerIdentity": stringToValueOrUndefined(configuration.PeerIdentity), "iceCandidatePoolSize": uint8ToValueOrUndefined(configuration.ICECandidatePoolSize), "alwaysNegotiateDataChannels": boolToValueOrUndefined(configuration.AlwaysNegotiateDataChannels), + "rtpHeaderEncryptionPolicy": stringEnumToValueOrUndefined(configuration.RTPHeaderEncryptionPolicy.String()), // Note: Certificates are not currently supported. // "certificates": configuration.Certificates, @@ -624,6 +625,8 @@ func valueToConfiguration(configValue js.Value) Configuration { PeerIdentity: valueToStringOrZero(configValue.Get("peerIdentity")), ICECandidatePoolSize: valueToUint8OrZero(configValue.Get("iceCandidatePoolSize")), AlwaysNegotiateDataChannels: valueToBoolOrFalse(configValue.Get("alwaysNegotiateDataChannels")), + RTPHeaderEncryptionPolicy: newRTPHeaderEncryptionPolicy( + valueToStringOrZero(configValue.Get("rtpHeaderEncryptionPolicy"))), // Note: Certificates are not supported. // Certificates []Certificate diff --git a/peerconnection_test.go b/peerconnection_test.go index fe4f9033701..8f9ca5c987c 100644 --- a/peerconnection_test.go +++ b/peerconnection_test.go @@ -215,6 +215,7 @@ func TestPeerConnection_SetConfiguration(t *testing.T) { RTCPMuxPolicy: RTCPMuxPolicyRequire, ICECandidatePoolSize: 1, AlwaysNegotiateDataChannels: true, + RTPHeaderEncryptionPolicy: RTPHeaderEncryptionPolicyNegotiate, }) if err != nil { return pc, err @@ -319,11 +320,13 @@ func TestPeerConnection_GetConfiguration(t *testing.T) { assert.NoError(t, err) expected := Configuration{ - ICEServers: []ICEServer{}, - ICETransportPolicy: ICETransportPolicyAll, - BundlePolicy: BundlePolicyBalanced, - RTCPMuxPolicy: RTCPMuxPolicyRequire, - ICECandidatePoolSize: 0, + ICEServers: []ICEServer{}, + ICETransportPolicy: ICETransportPolicyAll, + BundlePolicy: BundlePolicyBalanced, + RTCPMuxPolicy: RTCPMuxPolicyRequire, + ICECandidatePoolSize: 0, + AlwaysNegotiateDataChannels: false, + RTPHeaderEncryptionPolicy: RTPHeaderEncryptionPolicyNegotiate, } actual := pc.GetConfiguration() assert.True(t, &expected != &actual) @@ -336,7 +339,7 @@ func TestPeerConnection_GetConfiguration(t *testing.T) { // See: https://github.com/pion/webrtc/issues/513. // assert.Equal(t, len(expected.Certificates), len(actual.Certificates)) assert.Equal(t, expected.ICECandidatePoolSize, actual.ICECandidatePoolSize) - assert.False(t, actual.AlwaysNegotiateDataChannels) + assert.Equal(t, expected.AlwaysNegotiateDataChannels, actual.AlwaysNegotiateDataChannels) assert.NoError(t, pc.Close()) } diff --git a/rtpheaderencryptionpolicy.go b/rtpheaderencryptionpolicy.go new file mode 100644 index 00000000000..4e554f903d8 --- /dev/null +++ b/rtpheaderencryptionpolicy.go @@ -0,0 +1,81 @@ +// SPDX-FileCopyrightText: 2026 The Pion community +// SPDX-License-Identifier: MIT + +// nolint:dupl,staticcheck +package webrtc + +import ( + "encoding/json" +) + +// RTPHeaderEncryptionPolicy affects whether RTP header extension encryption is negotiated if the remote endpoint +// does not support RFC 9335. If the remote endpoint supports RFC 9335, all media streams are sent utilizing RFC 9335. +type RTPHeaderEncryptionPolicy int + +const ( + // RTPHeaderEncryptionPolicyUnknown is the enum's zero-value. + RTPHeaderEncryptionPolicyUnknown RTPHeaderEncryptionPolicy = iota + + // RTPHeaderEncryptionPolicyDisable indicates to disable RTP header extension encryption. This disables negotiation + // of RTP header extension encryption as defined in RFC 9335 using the "cryptex" SDP attribute. + RTPHeaderEncryptionPolicyDisable + + // RTPHeaderEncryptionPolicyNegotiate indicates to negotiate RTP header extension encryption as defined in RFC 9335. + // If encryption cannot be negotiated, RTP header extensions are sent in the clear. + RTPHeaderEncryptionPolicyNegotiate + + // RTPHeaderEncryptionPolicyRequire indicates to require RTP header extension encryption. If encryption cannot be + // negotiated, session negotiation will fail. + RTPHeaderEncryptionPolicyRequire +) + +// This is done this way because of a linter. +const ( + rtpHeaderEncryptionPolicyDisable = "disable" + rtpHeaderEncryptionPolicyNegotiateStr = "negotiate" + rtpHeaderEncryptionPolicyRequireStr = "require" +) + +func newRTPHeaderEncryptionPolicy(raw string) RTPHeaderEncryptionPolicy { + switch raw { + case rtpHeaderEncryptionPolicyDisable: + return RTPHeaderEncryptionPolicyDisable + case rtpHeaderEncryptionPolicyNegotiateStr: + return RTPHeaderEncryptionPolicyNegotiate + case rtpHeaderEncryptionPolicyRequireStr: + return RTPHeaderEncryptionPolicyRequire + default: + return RTPHeaderEncryptionPolicyUnknown + } +} + +// String returns the string representation of the RTPHeaderEncryptionPolicy. +func (t RTPHeaderEncryptionPolicy) String() string { + switch t { + case RTPHeaderEncryptionPolicyDisable: + return rtpHeaderEncryptionPolicyDisable + case RTPHeaderEncryptionPolicyNegotiate: + return rtpHeaderEncryptionPolicyNegotiateStr + case RTPHeaderEncryptionPolicyRequire: + return rtpHeaderEncryptionPolicyRequireStr + default: + return ErrUnknownType.Error() + } +} + +// UnmarshalJSON parses the JSON-encoded data and stores the result. +func (t *RTPHeaderEncryptionPolicy) UnmarshalJSON(b []byte) error { + var val string + if err := json.Unmarshal(b, &val); err != nil { + return err + } + + *t = newRTPHeaderEncryptionPolicy(val) + + return nil +} + +// MarshalJSON returns the JSON encoding. +func (t RTPHeaderEncryptionPolicy) MarshalJSON() ([]byte, error) { + return json.Marshal(t.String()) +} diff --git a/rtpheaderencryptionpolicy_test.go b/rtpheaderencryptionpolicy_test.go new file mode 100644 index 00000000000..837b91bdb6a --- /dev/null +++ b/rtpheaderencryptionpolicy_test.go @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: 2026 The Pion community +// SPDX-License-Identifier: MIT + +package webrtc + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewRTPHeaderEncryptionPolicy(t *testing.T) { + testCases := []struct { + policyString string + expectedPolicy RTPHeaderEncryptionPolicy + }{ + {ErrUnknownType.Error(), RTPHeaderEncryptionPolicyUnknown}, + {"disable", RTPHeaderEncryptionPolicyDisable}, + {"negotiate", RTPHeaderEncryptionPolicyNegotiate}, + {"require", RTPHeaderEncryptionPolicyRequire}, + } + + for i, testCase := range testCases { + assert.Equal(t, + testCase.expectedPolicy, + newRTPHeaderEncryptionPolicy(testCase.policyString), + "testCase: %d %v", i, testCase, + ) + } +} + +func TestRTPHeaderEncryptionPolicy_String(t *testing.T) { + testCases := []struct { + policy RTPHeaderEncryptionPolicy + expectedString string + }{ + {RTPHeaderEncryptionPolicyUnknown, ErrUnknownType.Error()}, + {RTPHeaderEncryptionPolicyDisable, "disable"}, + {RTPHeaderEncryptionPolicyNegotiate, "negotiate"}, + {RTPHeaderEncryptionPolicyRequire, "require"}, + } + + for i, testCase := range testCases { + assert.Equal(t, + testCase.expectedString, + testCase.policy.String(), + "testCase: %d %v", i, testCase, + ) + } +} + +func TestRTPHeaderEncryptionPolicy_MarshalUnmarshal(t *testing.T) { + testCases := []RTPHeaderEncryptionPolicy{ + RTPHeaderEncryptionPolicyUnknown, + RTPHeaderEncryptionPolicyDisable, + RTPHeaderEncryptionPolicyNegotiate, + RTPHeaderEncryptionPolicyRequire, + } + + for i, testCase := range testCases { + bytes, err := testCase.MarshalJSON() + assert.NoError(t, err, "testCase: %d %v", i, testCase) + + var policy RTPHeaderEncryptionPolicy + err = policy.UnmarshalJSON(bytes) + assert.NoError(t, err, "testCase: %d %v", i, testCase) + + assert.Equal(t, + testCase, + policy, + "testCase: %d %v", i, testCase, + ) + } +} diff --git a/rtptransceiver.go b/rtptransceiver.go index 8eb5a38e366..051c536a2b5 100644 --- a/rtptransceiver.go +++ b/rtptransceiver.go @@ -253,6 +253,25 @@ func (t *RTPTransceiver) Direction() RTPTransceiverDirection { return RTPTransceiverDirection(0) } +// RTPHeaderEncryptionNegotiated reports if RFC 9335 RTP Header Extension Encryption ("Cryptex") +// has been negotiated and is enabled for this transceiver. +func (t *RTPTransceiver) RTPHeaderEncryptionNegotiated() bool { + var dtlsTransport *DTLSTransport + if sender := t.Sender(); sender != nil { + dtlsTransport = sender.Transport() + } + if dtlsTransport == nil { + if receiver := t.Receiver(); receiver != nil { + dtlsTransport = receiver.Transport() + } + } + if dtlsTransport == nil { + return false + } + + return dtlsTransport.rtpHeaderEncryptionNegotiated() +} + // Stop irreversibly stops the RTPTransceiver. func (t *RTPTransceiver) Stop() error { if sender := t.Sender(); sender != nil { diff --git a/sdp.go b/sdp.go index 2ca44f41d7a..b5fc0a0f73b 100644 --- a/sdp.go +++ b/sdp.go @@ -640,6 +640,10 @@ func addTransceiverSDP( media.WithValueAttribute(sdpAttributeSimulcast, "recv "+strings.Join(recvRids, ";")) } + if mediaSection.cryptex { + media = media.WithPropertyAttribute(sdp.AttrKeyCryptex) + } + addSenderSDP(mediaSection, isPlanB, media) media = media.WithPropertyAttribute(transceiver.Direction().String()) @@ -671,6 +675,7 @@ type mediaSection struct { data bool matchExtensions map[string]int rids []*simulcastRid + cryptex bool } func bundleMatchFromRemote(matchBundleGroup *string) func(mid string) bool { @@ -688,7 +693,7 @@ func bundleMatchFromRemote(matchBundleGroup *string) func(mid string) bool { // populateSDP serializes a PeerConnections state into an SDP. // -//nolint:cyclop +//nolint:cyclop,gocognit func populateSDP( descr *sdp.SessionDescription, isPlanB bool, @@ -705,6 +710,7 @@ func populateSDP( matchBundleGroup *string, sctpMaxMessageSize uint32, ignoreRidPauseForRecv bool, + cryptexAtSessionLevel bool, ) (*sdp.SessionDescription, error) { var err error mediaDtlsFingerprints := []DTLSFingerprint{} @@ -722,6 +728,8 @@ func populateSDP( bundleCount++ } + haveActiveRTPMedia := false + for i, section := range mediaSections { if section.data && len(section.transceivers) != 0 { return nil, errSDPMediaSectionMediaDataChanInvalid @@ -772,6 +780,15 @@ func populateSDP( descr.MediaDescriptions[len(descr.MediaDescriptions)-1].MediaName.Port = sdp.RangedPort{Value: 0} } } + + // Determine if we have any active RTP m-lines. + // We only add session-level Cryptex if there is at least one RTP m-line with a non-zero port. + if !section.data { + md := descr.MediaDescriptions[len(descr.MediaDescriptions)-1] + if md.MediaName.Port.Value != 0 && md.MediaName.Media != mediaSectionApplication { + haveActiveRTPMedia = true + } + } } if !mediaDescriptionFingerprint { @@ -789,6 +806,10 @@ func populateSDP( descr = descr.WithPropertyAttribute(sdp.AttrKeyExtMapAllowMixed) } + if cryptexAtSessionLevel && haveActiveRTPMedia { + descr.WithPropertyAttribute(sdp.AttrKeyCryptex) + } + if bundleCount > 0 { descr = descr.WithValueAttribute(sdp.AttrKeyGroup, bundleValue) } @@ -1199,6 +1220,16 @@ func isExtMapAllowMixedSet(desc *sdp.SessionDescription) bool { return false } +func isCryptexSet(desc *sdp.MediaDescription) bool { + for _, a := range desc.Attributes { + if strings.TrimSpace(a.Key) == sdp.AttrKeyCryptex { + return true + } + } + + return false +} + func getMaxMessageSize(desc *sdp.MediaDescription) uint32 { for _, a := range desc.Attributes { if strings.TrimSpace(a.Key) == "max-message-size" { diff --git a/sdp_test.go b/sdp_test.go index 876530fc11e..0c41db6f523 100644 --- a/sdp_test.go +++ b/sdp_test.go @@ -761,6 +761,7 @@ func TestMediaDescriptionFingerprints(t *testing.T) { nil, 0, false, + false, ) assert.NoError(t, err) @@ -816,6 +817,7 @@ func TestPopulateSDP(t *testing.T) { //nolint:gocyclo,cyclop,maintidx nil, se.getSCTPMaxMessageSize(), se.ignoreRidPauseForRecv, + false, ) assert.Nil(t, err) @@ -878,6 +880,7 @@ func TestPopulateSDP(t *testing.T) { //nolint:gocyclo,cyclop,maintidx nil, se.getSCTPMaxMessageSize(), se.ignoreRidPauseForRecv, + false, ) assert.Nil(t, err) @@ -938,6 +941,7 @@ func TestPopulateSDP(t *testing.T) { //nolint:gocyclo,cyclop,maintidx nil, se.getSCTPMaxMessageSize(), false, + false, ) assert.Nil(t, err) @@ -979,6 +983,7 @@ func TestPopulateSDP(t *testing.T) { //nolint:gocyclo,cyclop,maintidx nil, se.getSCTPMaxMessageSize(), false, + false, ) assert.Nil(t, err) @@ -1038,6 +1043,7 @@ func TestPopulateSDP(t *testing.T) { //nolint:gocyclo,cyclop,maintidx nil, se.getSCTPMaxMessageSize(), false, + false, ) assert.NoError(t, err) @@ -1072,6 +1078,7 @@ func TestPopulateSDP(t *testing.T) { //nolint:gocyclo,cyclop,maintidx nil, se.getSCTPMaxMessageSize(), false, + false, ) assert.Nil(t, err) @@ -1103,6 +1110,7 @@ func TestPopulateSDP(t *testing.T) { //nolint:gocyclo,cyclop,maintidx nil, se.getSCTPMaxMessageSize(), false, + false, ) assert.Nil(t, err) @@ -1148,6 +1156,7 @@ func TestPopulateSDP(t *testing.T) { //nolint:gocyclo,cyclop,maintidx nil, se.getSCTPMaxMessageSize(), false, + false, ) assert.Nil(t, err) @@ -1189,6 +1198,7 @@ func TestPopulateSDP(t *testing.T) { //nolint:gocyclo,cyclop,maintidx &matchedBundle, se.getSCTPMaxMessageSize(), false, + false, ) assert.Nil(t, err) @@ -1232,6 +1242,7 @@ func TestPopulateSDP(t *testing.T) { //nolint:gocyclo,cyclop,maintidx &matchedBundle, se.getSCTPMaxMessageSize(), false, + false, ) assert.Nil(t, err) @@ -1266,6 +1277,7 @@ func TestPopulateSDP(t *testing.T) { //nolint:gocyclo,cyclop,maintidx nil, se.getSCTPMaxMessageSize(), false, + false, ) assert.Nil(t, err) @@ -1275,6 +1287,83 @@ func TestPopulateSDP(t *testing.T) { //nolint:gocyclo,cyclop,maintidx } } }) + t.Run("cryptex media-level", func(t *testing.T) { + se := SettingEngine{} + + me := &MediaEngine{} + assert.NoError(t, me.RegisterDefaultCodecs()) + api := NewAPI(WithMediaEngine(me)) + + tr := &RTPTransceiver{kind: RTPCodecTypeVideo, api: api, codecs: me.videoCodecs} + tr.setDirection(RTPTransceiverDirectionRecvonly) + mediaSections := []mediaSection{{id: "video", transceivers: []*RTPTransceiver{tr}, cryptex: true}} + + d := &sdp.SessionDescription{} + offerSdp, err := populateSDP( + d, + false, + []DTLSFingerprint{}, + se.sdpMediaLevelFingerprints, + se.candidates.ICELite, + true, + me, + connectionRoleFromDtlsRole(defaultDtlsRoleOffer), + []ICECandidate{}, + ICEParameters{}, + mediaSections, + ICEGatheringStateComplete, + nil, + se.getSCTPMaxMessageSize(), + se.ignoreRidPauseForRecv, + false, + ) + assert.NoError(t, err) + + found := false + for _, md := range offerSdp.MediaDescriptions { + if md.MediaName.Media != string(MediaKindVideo) { + continue + } + _, ok := md.Attribute(sdp.AttrKeyCryptex) + found = found || ok + } + assert.True(t, found, "expected a=cryptex on video media section") + }) + t.Run("cryptex session-level", func(t *testing.T) { + se := SettingEngine{} + + me := &MediaEngine{} + assert.NoError(t, me.RegisterDefaultCodecs()) + api := NewAPI(WithMediaEngine(me)) + + tr := &RTPTransceiver{kind: RTPCodecTypeVideo, api: api, codecs: me.videoCodecs} + tr.setDirection(RTPTransceiverDirectionRecvonly) + mediaSections := []mediaSection{{id: "video", transceivers: []*RTPTransceiver{tr}}} + + d := &sdp.SessionDescription{} + offerSdp, err := populateSDP( + d, + false, + []DTLSFingerprint{}, + se.sdpMediaLevelFingerprints, + se.candidates.ICELite, + true, + me, + connectionRoleFromDtlsRole(defaultDtlsRoleOffer), + []ICECandidate{}, + ICEParameters{}, + mediaSections, + ICEGatheringStateComplete, + nil, + se.getSCTPMaxMessageSize(), + se.ignoreRidPauseForRecv, + true, + ) + assert.NoError(t, err) + + _, ok := offerSdp.Attribute(sdp.AttrKeyCryptex) + assert.True(t, ok, "expected session-level a=cryptex") + }) } func TestGetRIDs(t *testing.T) { diff --git a/settingengine_js.go b/settingengine_js.go index 3b2394bbbe9..40d9ec47e0d 100644 --- a/settingengine_js.go +++ b/settingengine_js.go @@ -13,6 +13,8 @@ type SettingEngine struct { detach struct { DataChannels bool } + + disableRTPHeaderEncryption bool } // DetachDataChannels enables detaching data channels. When enabled @@ -21,3 +23,9 @@ type SettingEngine struct { func (e *SettingEngine) DetachDataChannels() { e.detach.DataChannels = true } + +// DisableRTPHeaderEncryption disables RFC 9335 Cryptex negotiation. +// When isDisabled is true, the PeerConnection will not advertise Cryptex in generated SDP. +func (e *SettingEngine) DisableRTPHeaderEncryption(isDisabled bool) { + e.disableRTPHeaderEncryption = isDisabled +}