Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
2 changes: 1 addition & 1 deletion p2p/test/transport/rcmgr_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ func TestResourceManagerIsUsed(t *testing.T) {
}
return nil
})
if tc.Name == "WebRTC" {
if strings.Contains(tc.Name, "WebRTC") {
// webrtc receive buffer is a fix sized buffer allocated up front
connScope.EXPECT().ReserveMemory(gomock.Any(), gomock.Any())
}
Expand Down
31 changes: 25 additions & 6 deletions p2p/test/transport/transport_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -339,10 +339,29 @@ var transportsToTest = []TransportTestCase{
},
},
{
Name: "WebRTC",
// WebRTC-v1 dials with the v1 flow (SDP munging).
Name: "WebRTC-v1",
HostGenerator: func(t *testing.T, opts TransportTestCaseOpts) host.Host {
libp2pOpts := transformOpts(opts)
libp2pOpts = append(libp2pOpts, libp2p.Transport(libp2pwebrtc.New))
libp2pOpts = append(libp2pOpts, libp2p.Transport(libp2pwebrtc.New, libp2pwebrtc.WithDialerVersion(1)))
if opts.NoListen {
libp2pOpts = append(libp2pOpts, libp2p.NoListenAddrs)
} else {
libp2pOpts = append(libp2pOpts, libp2p.ListenAddrStrings("/ip4/127.0.0.1/udp/0/webrtc-direct"))
}
h, err := libp2p.New(libp2pOpts...)
require.NoError(t, err)
return h
},
},
{
// WebRTC-v2 dials with the v2 flow (no SDP munging). The listener accepts
// both versions and picks one from the ufrag prefix, so only the dialer
// needs the option; the same generator is fine for both hosts.
Name: "WebRTC-v2",
HostGenerator: func(t *testing.T, opts TransportTestCaseOpts) host.Host {
libp2pOpts := transformOpts(opts)
libp2pOpts = append(libp2pOpts, libp2p.Transport(libp2pwebrtc.New, libp2pwebrtc.WithDialerVersion(2)))
if opts.NoListen {
libp2pOpts = append(libp2pOpts, libp2p.NoListenAddrs)
} else {
Expand Down Expand Up @@ -893,7 +912,7 @@ func TestDiscoverPeerIDFromSecurityNegotiation(t *testing.T) {
func TestCloseConnWhenBlocked(t *testing.T) {
for _, tc := range transportsToTest {
// WebRTC doesn't have a connection when rcmgr blocks it, so there's nothing to close.
if tc.Name == "WebRTC" {
if strings.Contains(tc.Name, "WebRTC") {
continue
}
t.Run(tc.Name, func(t *testing.T) {
Expand Down Expand Up @@ -933,7 +952,7 @@ func TestCloseConnWhenBlocked(t *testing.T) {
// connection attempt
func TestConnDroppedWhenBlocked(t *testing.T) {
for _, tc := range transportsToTest {
if tc.Name != "WebRTC" {
if !strings.Contains(tc.Name, "WebRTC") {
continue
}
t.Run(tc.Name, func(t *testing.T) {
Expand Down Expand Up @@ -1096,7 +1115,7 @@ func TestErrorCodes(t *testing.T) {
})

t.Run("StreamResetByConnCloseWithError", func(t *testing.T) {
if tc.Name == "WebRTC" {
if strings.Contains(tc.Name, "WebRTC") {
t.Skipf("skipping: %s, not implemented", tc.Name)
return
}
Expand Down Expand Up @@ -1124,7 +1143,7 @@ func TestErrorCodes(t *testing.T) {
})

t.Run("NewStreamErrorByConnCloseWithError", func(t *testing.T) {
if tc.Name == "WebRTC" {
if strings.Contains(tc.Name, "WebRTC") {
t.Skipf("skipping: %s, not implemented", tc.Name)
return
}
Expand Down
103 changes: 99 additions & 4 deletions p2p/transport/webrtc/listener.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,67 @@ var _ network.ConnMultiaddrs = &connMultiaddrs{}
func (c *connMultiaddrs) LocalMultiaddr() ma.Multiaddr { return c.local }
func (c *connMultiaddrs) RemoteMultiaddr() ma.Multiaddr { return c.remote }

// The "libp2p+webrtc+" namespace selects the WebRTC Direct handshake version via
// the ICE username fragment. v1 ("libp2p+webrtc+v1/") munges the SDP; v2
// ("libp2p+webrtc+v2/") does not, because Chromium's WebRTC-NoSdpMangleUfrag
// field trial rejects an offer whose ICE ufrag was munged. A v2 client instead
// leaves its local credentials untouched and puts its own ICE password in the
// server ufrag as "libp2p+webrtc+v2/<client_pwd>", which the server reads back
// from the STUN USERNAME with no SDP exchange. A server MUST reject a version it
// does not recognize rather than guess one. v2 was introduced in
// https://github.com/libp2p/specs/pull/715; the spec lives at
// https://github.com/libp2p/specs/blob/master/webrtc/webrtc-direct.md and the
// js-libp2p implementation at https://github.com/libp2p/js-libp2p/pull/3480.
const (
ufragPrefixV1 = "libp2p+webrtc+v1/"
ufragPrefixV2 = "libp2p+webrtc+v2/"
)

const (
// ICE credential bounds, per RFC 8839 section 5.4
// (ufrag = 4*256ice-char, password = 22*256ice-char).
iceUfragMinLen = 4
icePwdMinLen = 22
iceCredentialMax = 256
)

// ice-char is ASCII-only, so any string that passes isICECharString has exactly
// one byte per character. The len() byte count used by isICEUfrag/isICEPwd then
// equals both the ice-char count and the UTF-16 length that js-libp2p measures,
// so the two implementations accept the same credentials; non-ice-char input
// (including any multi-byte UTF-8) is rejected by both regardless of how each
// counts length.

// isICEChar reports whether b is in the RFC 8839 ice-char set
// (ALPHA / DIGIT / "+" / "/").
func isICEChar(b byte) bool {
return (b >= 'a' && b <= 'z') ||
(b >= 'A' && b <= 'Z') ||
(b >= '0' && b <= '9') ||
b == '+' || b == '/'
}

func isICECharString(s string) bool {
for i := 0; i < len(s); i++ {
if !isICEChar(s[i]) {
return false
}
}
return true
}

// isICEUfrag reports whether s is a syntactically valid ICE username fragment
// (RFC 8839 section 5.4: ufrag = 4*256ice-char).
func isICEUfrag(s string) bool {
return len(s) >= iceUfragMinLen && len(s) <= iceCredentialMax && isICECharString(s)
}

// isICEPwd reports whether s is a syntactically valid ICE password
// (RFC 8839 section 5.4: password = 22*256ice-char).
func isICEPwd(s string) bool {
return len(s) >= icePwdMinLen && len(s) <= iceCredentialMax && isICECharString(s)
}

const (
candidateSetupTimeout = 10 * time.Second
// This is higher than other transports(64) as there's no way to detect a peer that has gone away after
Expand Down Expand Up @@ -196,9 +257,41 @@ func (l *listener) setupConnection(
}
}()

// candidate.Ufrag is the server (local) ufrag and candidate.RemoteUfrag is the
// client ufrag, parsed from the STUN USERNAME ("server_ufrag:client_ufrag",
// RFC 8445 section 7.2.2). Both come from an attacker-controlled STUN packet
// and are templated into the inferred SDP offer and pion's ICE credentials, so
// reject anything that is not a valid ICE username fragment (ice-char, length
// 4..256) per RFC 8839 section 5.4 before using it.
serverUfrag := candidate.Ufrag
clientUfrag := candidate.RemoteUfrag
if !isICEUfrag(serverUfrag) || !isICEUfrag(clientUfrag) {
return nil, fmt.Errorf("invalid ICE ufrag in STUN username")
}
// Select the version explicitly from the server ufrag prefix. The client
// password lets us render a valid remote (client) SDP offer: in v1 the client
// uses one shared value (client password == client ufrag); in v2 the client
// encodes its password into the server ufrag as "libp2p+webrtc+v2/<client_pwd>".
// An unrecognized version is rejected, never treated as v1. See https://github.com/libp2p/specs/blob/master/webrtc/webrtc-direct.md.
var clientPwd string
switch {
case strings.HasPrefix(serverUfrag, ufragPrefixV2):
pwd := strings.TrimPrefix(serverUfrag, ufragPrefixV2)
// The recovered value becomes the inferred offer's ice-pwd, so it must be a
// valid ICE password (ice-char, length 22..256) per RFC 8839 section 5.4.
if !isICEPwd(pwd) {
return nil, fmt.Errorf("invalid v2 ufrag %q: recovered client password is not a valid ICE password", serverUfrag)
}
clientPwd = pwd
case strings.HasPrefix(serverUfrag, ufragPrefixV1):
clientPwd = clientUfrag
default:
return nil, fmt.Errorf("unsupported WebRTC Direct version in ufrag %q", serverUfrag)
}
Comment thread
sukunrt marked this conversation as resolved.
Outdated

settingEngine := webrtc.SettingEngine{LoggerFactory: pionLoggerFactory}
settingEngine.SetAnsweringDTLSRole(webrtc.DTLSRoleServer)
settingEngine.SetICECredentials(candidate.Ufrag, candidate.Ufrag)
settingEngine.SetICECredentials(serverUfrag, serverUfrag)
settingEngine.SetLite(true)
settingEngine.SetICEUDPMux(l.mux)
settingEngine.SetIncludeLoopbackCandidate(true)
Expand All @@ -224,9 +317,11 @@ func (l *listener) setupConnection(
}

errC := addOnConnectionStateChangeCallback(w.PeerConnection)
// Infer the client SDP from the incoming STUN message by setting the ice-ufrag.
// Infer the client SDP offer from the incoming STUN message using the client
// ufrag and password. pion validates the full "server_ufrag:client_ufrag"
// USERNAME on inbound checks, so the remote ice-ufrag must be the client ufrag.
if err := w.PeerConnection.SetRemoteDescription(webrtc.SessionDescription{
SDP: createClientSDP(candidate.Addr, candidate.Ufrag),
SDP: createClientSDP(candidate.Addr, clientUfrag, clientPwd),
Type: webrtc.SDPTypeOffer,
}); err != nil {
return nil, err
Expand All @@ -244,7 +339,7 @@ func (l *listener) setupConnection(
return nil, ctx.Err()
case err := <-errC:
if err != nil {
return nil, fmt.Errorf("peer connection failed for ufrag: %s", candidate.Ufrag)
return nil, fmt.Errorf("peer connection failed for ufrag: %s", serverUfrag)
}
}

Expand Down
9 changes: 7 additions & 2 deletions p2p/transport/webrtc/sdp.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ import (
// The fingerprint used to render a client SDP is arbitrary since
// it fingerprint verification is disabled in favour of a noise
// handshake. The max message size is fixed to 16384 bytes.
//
// The ice-ufrag and ice-pwd are rendered separately. In WebRTC Direct v1 they
// are the same shared value; in v2 the client ufrag and the recovered client
// password differ. See https://github.com/libp2p/specs/blob/master/webrtc/webrtc-direct.md.
const clientSDP = `v=0
o=- 0 0 IN %[1]s %[2]s
s=-
Expand All @@ -24,14 +28,14 @@ m=application %[3]d UDP/DTLS/SCTP webrtc-datachannel
a=mid:0
a=ice-options:ice2
a=ice-ufrag:%[4]s
a=ice-pwd:%[4]s
a=ice-pwd:%[5]s
a=fingerprint:sha-256 ba:78:16:bf:8f:01:cf:ea:41:41:40:de:5d:ae:22:23:b0:03:61:a3:96:17:7a:9c:b4:10:ff:61:f2:00:15:ad
a=setup:actpass
a=sctp-port:5000
a=max-message-size:16384
`

func createClientSDP(addr *net.UDPAddr, ufrag string) string {
func createClientSDP(addr *net.UDPAddr, ufrag, pwd string) string {
ipVersion := "IP4"
if addr.IP.To4() == nil {
ipVersion = "IP6"
Expand All @@ -42,6 +46,7 @@ func createClientSDP(addr *net.UDPAddr, ufrag string) string {
addr.IP,
addr.Port,
ufrag,
pwd,
)
}

Expand Down
47 changes: 45 additions & 2 deletions p2p/transport/webrtc/sdp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,43 @@ package libp2pwebrtc
import (
"encoding/hex"
"net"
"strings"
"testing"

"github.com/multiformats/go-multihash"
"github.com/stretchr/testify/require"
)

// ICE username fragments and passwords are parsed from an attacker-controlled
// STUN USERNAME and templated into the inferred client SDP offer, so they must
// be validated against the ice-char set and length bounds in RFC 8839 section
// 5.4 before use.
func TestICECredentialValidation(t *testing.T) {
v2Ufrag := ufragPrefixV2 + strings.Repeat("a", 24)
require.True(t, isICEUfrag("abcd")) // 4 chars, minimum ufrag
require.True(t, isICEUfrag("libp2p+webrtc+v1/abcdEFGH")) // '+' and '/' are ice-char
require.True(t, isICEUfrag(v2Ufrag))
require.True(t, isICEPwd(strings.Repeat("a", 22))) // 22 chars, minimum password

// charset violations (e.g. CRLF/SDP injection attempts)
require.False(t, isICEUfrag("abc\r\na=candidate:x"))
require.False(t, isICEUfrag("ab:cd")) // ':' is not an ice-char
require.False(t, isICEPwd(strings.Repeat("a", 21)+"\n"))

// length violations
require.False(t, isICEUfrag("abc")) // 3 < 4
require.False(t, isICEUfrag("")) // empty
require.False(t, isICEPwd(strings.Repeat("a", 21))) // 21 < 22
require.False(t, isICEUfrag(strings.Repeat("a", 257))) // > 256

// multi-byte UTF-8 input: the len() byte count differs from the character
// count (and from the UTF-16 length js-libp2p measures), but both reject it
// on the charset check, so the length-representation difference between the
// implementations never changes the decision.
require.False(t, isICEUfrag("abécd")) // 'é' is 2 UTF-8 bytes and not ice-char
require.False(t, isICEUfrag("ab😀cd")) // emoji: 4 UTF-8 bytes / 2 UTF-16 units
}

const expectedServerSDP = `v=0
o=- 0 0 IN IP4 0.0.0.0
s=-
Expand Down Expand Up @@ -68,16 +99,28 @@ a=max-message-size:16384
func TestRenderClientSDP(t *testing.T) {
addr := &net.UDPAddr{IP: net.IPv4(0, 0, 0, 0), Port: 37826}
ufrag := "d2c0fc07-8bb3-42ae-bae2-a6fce8a0b581"
sdp := createClientSDP(addr, ufrag)
sdp := createClientSDP(addr, ufrag, ufrag)
require.Equal(t, expectedClientSDP, sdp)
}

// In WebRTC Direct v2 the inferred client offer carries distinct ice-ufrag and
// ice-pwd values: the client ufrag from the STUN USERNAME and the client
// password recovered from the "libp2p+webrtc+v2/<client_pwd>" server ufrag.
func TestRenderClientSDPV2DistinctCredentials(t *testing.T) {
addr := &net.UDPAddr{IP: net.IPv4(0, 0, 0, 0), Port: 37826}
clientUfrag := "browserClientUfrag"
clientPwd := "browserClientPassword1234"
sdp := createClientSDP(addr, clientUfrag, clientPwd)
require.Contains(t, sdp, "a=ice-ufrag:"+clientUfrag+"\n")
require.Contains(t, sdp, "a=ice-pwd:"+clientPwd+"\n")
}

func BenchmarkRenderClientSDP(b *testing.B) {
addr := &net.UDPAddr{IP: net.IPv4(0, 0, 0, 0), Port: 37826}
ufrag := "d2c0fc07-8bb3-42ae-bae2-a6fce8a0b581"

for i := 0; i < b.N; i++ {
createClientSDP(addr, ufrag)
createClientSDP(addr, ufrag, ufrag)
}
}

Expand Down
Loading
Loading