diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f2ecaae..55bd625 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,6 +70,4 @@ jobs: run: | nim --version nimble --version - # nimble test - # nim c examples/ping.nim - # nim c examples/pong.nim + nimble test diff --git a/tests/runalltests.nim b/tests/runalltests.nim new file mode 100644 index 0000000..764ff3c --- /dev/null +++ b/tests/runalltests.nim @@ -0,0 +1,12 @@ +# Nim-WebRTC +# Copyright (c) 2024 Status Research & Development GmbH +# Licensed under either of +# * Apache License, version 2.0, ([LICENSE-APACHE](LICENSE-APACHE)) +# * MIT license ([LICENSE-MIT](LICENSE-MIT)) +# at your option. +# This file may not be copied, modified, or distributed except according to +# those terms. + +{.used.} + +import teststun diff --git a/tests/teststun.nim b/tests/teststun.nim new file mode 100644 index 0000000..76c70bd --- /dev/null +++ b/tests/teststun.nim @@ -0,0 +1,126 @@ +# Nim-WebRTC +# Copyright (c) 2024 Status Research & Development GmbH +# Licensed under either of +# * Apache License, version 2.0, ([LICENSE-APACHE](LICENSE-APACHE)) +# * MIT license ([LICENSE-MIT](LICENSE-MIT)) +# at your option. +# This file may not be copied, modified, or distributed except according to +# those terms. + +{.used.} + +import options +import bearssl +import ../webrtc/udp_connection +import ../webrtc/stun/stun_connection +import ../webrtc/stun/stun_message +import ../webrtc/stun/stun_attributes +import ./asyncunit + +proc newRng(): ref HmacDrbgContext = + HmacDrbgContext.new() + +proc usernameProvEmpty(): string = "" +proc usernameProvTest(): string {.raises: [], gcsafe.} = "TestUsername" +proc usernameCheckTrue(username: seq[byte]): bool {.raises: [], gcsafe.} = true +proc usernameCheckFalse(username: seq[byte]): bool {.raises: [], gcsafe.} = false +proc passwordProvEmpty(username: seq[byte]): seq[byte] {.raises: [], gcsafe.} = @[] +proc passwordProvTest(username: seq[byte]): seq[byte] {.raises: [], gcsafe.} = @[1'u8, 2, 3, 4] + +suite "Stun message encoding/decoding": + test "Get BindingRequest + encode & decode with a set username": + var + udpConn = UdpConn.init(AnyAddress) + conn = StunConn.new( + udpConn, + TransportAddress(AnyAddress), + iceControlling=true, + usernameProvider=usernameProvTest, + usernameChecker=usernameCheckTrue, + passwordProvider=passwordProvEmpty, + newRng() + ) + msg = conn.getBindingRequest() + encoded = msg.encode(@[1'u8, 2, 3, 4]) + decoded = StunMessage.decode(encoded) + messageIntegrity = decoded.attributes[^2] + fingerprint = decoded.attributes[^1] + + decoded.attributes = decoded.attributes[0 ..< ^2] + check: + decoded == msg + messageIntegrity.attributeType == AttrMessageIntegrity.uint16 + fingerprint.attributeType == AttrFingerprint.uint16 + conn.close() + + test "Get BindingResponse from BindingRequest + encode & decode": + var + udpConn = UdpConn.init(AnyAddress) + conn = StunConn.new( + udpConn, + TransportAddress(AnyAddress), + iceControlling=false, + usernameProvider=usernameProvTest, + usernameChecker=usernameCheckTrue, + passwordProvider=passwordProvEmpty, + newRng() + ) + bindingRequest = conn.getBindingRequest() + bindingResponse = conn.getBindingResponse(bindingRequest) + encoded = bindingResponse.encode(@[1'u8, 2, 3, 4]) + decoded = StunMessage.decode(encoded) + messageIntegrity = decoded.attributes[^2] + fingerprint = decoded.attributes[^1] + + decoded.attributes = decoded.attributes[0 ..< ^2] + check: + bindingResponse == decoded + messageIntegrity.attributeType == AttrMessageIntegrity.uint16 + fingerprint.attributeType == AttrFingerprint.uint16 + +suite "Stun checkForError": + test "checkForError: Missing MessageIntegrity or Username": + var + udpConn = UdpConn.init(AnyAddress) + conn = StunConn.new( + udpConn, + TransportAddress(AnyAddress), + iceControlling=false, + usernameProvider=usernameProvEmpty, # Use of an empty username provider + usernameChecker=usernameCheckTrue, + passwordProvider=passwordProvEmpty, + newRng() + ) + bindingRequest = conn.getBindingRequest() + errorMissMessageIntegrity = conn.checkForError(bindingRequest).get() + + check: + errorMissMessageIntegrity.getAttribute(ErrorCode).get().getErrorCode() == ECBadRequest + + let + encoded = bindingRequest.encode(@[1'u8, 2, 3, 4]) # adds MessageIntegrity + decoded = StunMessage.decode(encoded) + errorMissUsername = conn.checkForError(decoded).get() + + check: + errorMissUsername.getAttribute(ErrorCode).get().getErrorCode() == ECBadRequest + + test "checkForError: UsernameChecker returns false": + var + udpConn = UdpConn.init(AnyAddress) + conn = StunConn.new( + udpConn, + TransportAddress(AnyAddress), + iceControlling=false, + usernameProvider=usernameProvTest, + usernameChecker=usernameCheckFalse, # Username provider returns false + passwordProvider=passwordProvEmpty, + newRng() + ) + bindingRequest = conn.getBindingRequest() + encoded = bindingRequest.encode(@[0'u8, 1, 2, 3]) + decoded = StunMessage.decode(encoded) + error = conn.checkForError(decoded).get() + + check: + error.getAttribute(ErrorCode).get().getErrorCode() == ECUnauthorized diff --git a/webrtc.nimble b/webrtc.nimble index c64ccac..ed35db0 100644 --- a/webrtc.nimble +++ b/webrtc.nimble @@ -33,5 +33,5 @@ proc runTest(filename: string) = exec excstr & " -r " & " tests/" & filename rmFile "tests/" & filename.toExe -# task test, "Run test": -# runTest("runalltests") +task test, "Run test": + runTest("runalltests") diff --git a/webrtc/stun/stun_attributes.nim b/webrtc/stun/stun_attributes.nim new file mode 100644 index 0000000..83b780c --- /dev/null +++ b/webrtc/stun/stun_attributes.nim @@ -0,0 +1,314 @@ +# Nim-WebRTC +# Copyright (c) 2024 Status Research & Development GmbH +# Licensed under either of +# * Apache License, version 2.0, ([LICENSE-APACHE](LICENSE-APACHE)) +# * MIT license ([LICENSE-MIT](LICENSE-MIT)) +# at your option. +# This file may not be copied, modified, or distributed except according to +# those terms. + +import sequtils, system, typetraits +import binary_serialization, + stew/[byteutils, objects], + chronos +import stun_utils + +# -- Attributes -- +# There are obviously some attributes implementation that are missing, +# it might be something to do eventually if we want to make this +# repository work for other project than nim-libp2p +# +# Stun Attribute +# 0 1 2 3 +# 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 +# +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +# | Type | Length | +# +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +# | Value (variable) .... +# +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + +type + RawStunAttribute* = object + attributeType*: uint16 + length* {.bin_value: it.value.len.}: uint16 + value* {.bin_len: it.length.}: seq[byte] + +proc decode*(T: typedesc[RawStunAttribute], cnt: seq[byte]): seq[RawStunAttribute] = + const pad = @[0, 3, 2, 1] + var padding = 0 + while padding < cnt.len(): + let attr = Binary.decode(cnt[padding ..^ 1], RawStunAttribute) + result.add(attr) + padding += 4 + attr.value.len() + padding += pad[padding mod 4] + +type + StunAttributeEnum* = enum + AttrMappedAddress = 0x0001 + AttrChangeRequest = 0x0003 # RFC5780 Nat Behavior Discovery + AttrSourceAddress = 0x0004 # Deprecated + AttrChangedAddress = 0x0005 # Deprecated + AttrUsername = 0x0006 + AttrMessageIntegrity = 0x0008 + AttrErrorCode = 0x0009 + AttrUnknownAttributes = 0x000A + AttrChannelNumber = 0x000C # RFC5766 TURN + AttrLifetime = 0x000D # RFC5766 TURN + AttrXORPeerAddress = 0x0012 # RFC5766 TURN + AttrData = 0x0013 # RFC5766 TURN + AttrRealm = 0x0014 + AttrNonce = 0x0015 + AttrXORRelayedAddress = 0x0016 # RFC5766 TURN + AttrRequestedAddressFamily = 0x0017 # RFC6156 + AttrEvenPort = 0x0018 # RFC5766 TURN + AttrRequestedTransport = 0x0019 # RFC5766 TURN + AttrDontFragment = 0x001A # RFC5766 TURN + AttrMessageIntegritySHA256 = 0x001C # RFC8489 STUN (v2) + AttrPasswordAlgorithm = 0x001D # RFC8489 STUN (v2) + AttrUserhash = 0x001E # RFC8489 STUN (v2) + AttrXORMappedAddress = 0x0020 + AttrReservationToken = 0x0022 # RFC5766 TURN + AttrPriority = 0x0024 # RFC5245 ICE + AttrUseCandidate = 0x0025 # RFC5245 ICE + AttrPadding = 0x0026 # RFC5780 Nat Behavior Discovery + AttrResponsePort = 0x0027 # RFC5780 Nat Behavior Discovery + AttrConnectionID = 0x002a # RFC6062 TURN Extensions + AttrPasswordAlgorithms = 0x8002 # RFC8489 STUN (v2) + AttrAlternateDomain = 0x8003 # RFC8489 STUN (v2) + AttrSoftware = 0x8022 + AttrAlternateServer = 0x8023 + AttrCacheTimeout = 0x8027 # RFC5780 Nat Behavior Discovery + AttrFingerprint = 0x8028 + AttrICEControlled = 0x8029 # RFC5245 ICE + AttrICEControlling = 0x802A # RFC5245 ICE + AttrResponseOrigin = 0x802b # RFC5780 Nat Behavior Discovery + AttrOtherAddress = 0x802C # RFC5780 Nat Behavior Discovery + AttrOrigin = 0x802F + +proc isRequired*(typ: uint16): bool = typ <= 0x7FFF'u16 +proc isOptional*(typ: uint16): bool = typ >= 0x8000'u16 +proc `==`*(x: uint16, y: StunAttributeEnum): bool = x == y.uint16 +proc `==`*(y: StunAttributeEnum, x: uint16): bool = x == y + +# Username +# https://datatracker.ietf.org/doc/html/rfc5389#section-15.3 + +type + UsernameAttribute* = object + username*: seq[byte] + +proc attributeType*(T: typedesc[UsernameAttribute]): StunAttributeEnum = AttrUsername + +proc encode*(T: typedesc[UsernameAttribute], username: seq[byte]): RawStunAttribute = + let + userAttr = UsernameAttribute(username: username) + value = Binary.encode(userAttr) + result = RawStunAttribute(attributeType: AttrUsername.uint16, + length: username.len().uint16, + value: value) + +proc encode*(T: typedesc[UsernameAttribute], username: string): RawStunAttribute = + return UsernameAttribute.encode(username.toBytes()) + +proc decode*(T: typedesc[UsernameAttribute], rawAttr: RawStunAttribute): T = + return Binary.decode(rawAttr.value, T) + +# Error Code +# https://datatracker.ietf.org/doc/html/rfc5389#section-15.6 + +type + ErrorCodeEnum* = enum + ECUnknownErrorCode = 0 + ECTryAlternate = 300 + ECBadRequest = 400 + ECUnauthorized = 401 + ECUnknownAttribute = 420 + ECStaleNonce = 438 + ECServerError = 500 + ErrorCode* = object + reserved1: uint16 # should be 0 + reserved2 {.bin_bitsize: 5.}: uint8 # should be 0 + class {.bin_bitsize: 3.}: uint8 + number: uint8 + reason: seq[byte] + +proc attributeType*(T: typedesc[ErrorCode]): StunAttributeEnum = AttrErrorCode + +proc getErrorCode*(self: ErrorCode): ErrorCodeEnum = + var res: ErrorCodeEnum + if not res.checkedEnumAssign(self.class.uint16 * 100 + self.number.uint16): + return ECUnknownErrorCode + return res + +proc encode*(T: typedesc[ErrorCode], code: ErrorCodeEnum, reason: string = ""): RawStunAttribute = + let + ec = T(class: (code.uint16 div 100'u16).uint8, + number: (code.uint16 mod 100'u16).uint8, + reason: @[]) # Reason should be encoded in utf-8, binary-serialization cannot do it + value = Binary.encode(ec) + result = RawStunAttribute(attributeType: AttrErrorCode.uint16, + length: value.len().uint16, + value: value) + +proc decode*(T: typedesc[ErrorCode], rawAttr: RawStunAttribute): T = + return Binary.decode(rawAttr.value, T) + +# Unknown Attributes +# https://datatracker.ietf.org/doc/html/rfc5389#section-15.9 + +type + UnknownAttributes* = object + unknownAttr: seq[uint16] + +proc attributeType*(T: typedesc[UnknownAttributes]): StunAttributeEnum = AttrUnknownAttributes + +proc encode*(T: typedesc[UnknownAttributes], unknownAttr: seq[uint16]): RawStunAttribute = + let + ua = T(unknownAttr: unknownAttr) + value = Binary.encode(ua) + result = RawStunAttribute(attributeType: AttrUnknownAttributes.uint16, + length: value.len().uint16, + value: value) + +proc decode*(T: typedesc[UnknownAttributes], rawAttr: RawStunAttribute): T = + return Binary.decode(rawAttr.value, T) + +# Fingerprint +# https://datatracker.ietf.org/doc/html/rfc5389#section-15.5 + +type + Fingerprint* = object + crc32: uint32 + +proc attributeType*(T: typedesc[Fingerprint]): StunAttributeEnum = AttrFingerprint + +proc encode*(T: typedesc[Fingerprint], msg: seq[byte]): RawStunAttribute = + let value = Binary.encode(T(crc32: crc32(msg) xor 0x5354554e'u32)) + result = RawStunAttribute(attributeType: AttrFingerprint.uint16, + length: value.len().uint16, + value: value) + +proc decode*(T: typedesc[Fingerprint], rawAttr: RawStunAttribute): T = + return Binary.decode(rawAttr.value, T) + +# Xor Mapped Address +# https://datatracker.ietf.org/doc/html/rfc5389#section-15.2 + +type + MappedAddressFamily {.size: 1.} = enum + MAFIPv4 = 0x01 + MAFIPv6 = 0x02 + + XorMappedAddress* = object + reserved: uint8 # should be 0 + family: MappedAddressFamily + port: uint16 + address: seq[byte] + +proc attributeType*(T: typedesc[XorMappedAddress]): StunAttributeEnum = AttrXORMappedAddress + +proc encode*(T: typedesc[XorMappedAddress], ta: TransportAddress, + tid: array[12, byte]): RawStunAttribute = + const magicCookie = @[ 0x21'u8, 0x12, 0xa4, 0x42 ] + let + (address, family) = + if ta.family == AddressFamily.IPv4: + var s = newSeq[uint8](4) + for i in 0..3: + s[i] = ta.address_v4[i] xor magicCookie[i] + (s, MAFIPv4) + else: + let magicCookieTid = magicCookie.concat(@tid) + var s = newSeq[uint8](16) + for i in 0..15: + s[i] = ta.address_v6[i] xor magicCookieTid[i] + (s, MAFIPv6) + xma = T(family: family, port: ta.port.distinctBase xor 0x2112'u16, address: address) + value = Binary.encode(xma) + result = RawStunAttribute(attributeType: AttrXORMappedAddress.uint16, + length: value.len().uint16, + value: value) + +proc decode*(T: typedesc[XorMappedAddress], rawAttr: RawStunAttribute): T = + return Binary.decode(rawAttr.value, T) + +# Message Integrity +# https://datatracker.ietf.org/doc/html/rfc5389#section-15.4 + +type + MessageIntegrity* = object + msgInt: seq[byte] + +proc attributeType*(T: typedesc[MessageIntegrity]): StunAttributeEnum = AttrMessageIntegrity + +proc encode*(T: typedesc[MessageIntegrity], msg: seq[byte], key: seq[byte]): RawStunAttribute = + let value = Binary.encode(T(msgInt: hmacSha1(key, msg))) + result = RawStunAttribute(attributeType: AttrMessageIntegrity.uint16, + length: value.len().uint16, value: value) + +proc decode*(T: typedesc[MessageIntegrity], rawAttr: RawStunAttribute): T = + return Binary.decode(rawAttr.value, T) + +# Priority +# https://datatracker.ietf.org/doc/html/rfc8445#section-7.1.1 +# https://datatracker.ietf.org/doc/html/rfc8445#section-5.1.2 + +type + Priority* = object + priority*: uint32 + +proc attributeType*(T: typedesc[Priority]): StunAttributeEnum = AttrPriority + +proc encode*(T: typedesc[Priority], priority: uint32): RawStunAttribute = + let value = Binary.encode(T(priority: priority)) + result = RawStunAttribute(attributeType: AttrPriority.uint16, + length: value.len().uint16, value: value) + +proc decode*(T: typedesc[Priority], rawAttr: RawStunAttribute): T = + return Binary.decode(rawAttr.value, T) + +# Use-Candidate +# https://datatracker.ietf.org/doc/html/rfc8445#section-7.1.2 +# https://datatracker.ietf.org/doc/html/rfc8445#section-8.1.1 +# Use-Candidate is empty because it's used as a flag + +type UseCandidate* = object + +proc attributeType*(T: typedesc[UseCandidate]): StunAttributeEnum = AttrUseCandidate + +proc encode*(T: typedesc[UseCandidate]): RawStunAttribute = + RawStunAttribute(attributeType: AttrUseCandidate.uint16, length: 0, value: @[]) + +proc decode*(T: typedesc[UseCandidate], rawAttr: RawStunAttribute): T = + return Binary.decode(rawAttr.value, T) + +# Ice-Controlling / Ice-Controlled +# https://datatracker.ietf.org/doc/html/rfc8445#section-7.1.3 +# https://datatracker.ietf.org/doc/html/rfc8445#section-7.3.1.1 + +type + IceControlling* = object + tieBreaker: uint32 + + IceControlled* = object + tieBreaker: uint32 + +proc attributeType*(T: typedesc[IceControlling]): StunAttributeEnum = AttrICEControlling + +proc attributeType*(T: typedesc[IceControlled]): StunAttributeEnum = AttrICEControlled + +proc encode*(T: typedesc[IceControlling], tieBreaker: uint32): RawStunAttribute = + let value = Binary.encode(T(tieBreaker: tieBreaker)) + result = RawStunAttribute(attributeType: AttrICEControlling.uint16, + length: value.len().uint16, value: value) + +proc encode*(T: typedesc[IceControlled], tieBreaker: uint32): RawStunAttribute = + let value = Binary.encode(T(tieBreaker: tieBreaker)) + result = RawStunAttribute(attributeType: AttrICEControlled.uint16, + length: value.len().uint16, value: value) + +proc decode*(T: typedesc[IceControlling], rawAttr: RawStunAttribute): T = + return Binary.decode(rawAttr.value, T) + +proc decode*(T: typedesc[IceControlled], rawAttr: RawStunAttribute): T = + return Binary.decode(rawAttr.value, T) diff --git a/webrtc/stun/stun_connection.nim b/webrtc/stun/stun_connection.nim new file mode 100644 index 0000000..a7a1c5f --- /dev/null +++ b/webrtc/stun/stun_connection.nim @@ -0,0 +1,249 @@ +# Nim-WebRTC +# Copyright (c) 2024 Status Research & Development GmbH +# Licensed under either of +# * Apache License, version 2.0, ([LICENSE-APACHE](LICENSE-APACHE)) +# * MIT license ([LICENSE-MIT](LICENSE-MIT)) +# at your option. +# This file may not be copied, modified, or distributed except according to +# those terms. + +import strutils +import bearssl, chronos, chronicles, stew/[objects, byteutils] +import ../[udp_connection, errors], stun_message, stun_attributes + +logScope: + topics = "webrtc stun stun_connection" + +# TODO: +# - Need to implement ICE-CONTROLL(ED|ING) for browser to browser (not critical) + +const + StunMaxQueuingMessages = 1024 + StunBindingRequest* = 0x0001'u16 + StunBindingResponse* = 0x0101'u16 + StunBindingErrorResponse* = 0x0111'u16 + +type + StunUsernameProvider* = proc(): string {.raises: [], gcsafe.} + StunUsernameChecker* = proc(username: seq[byte]): bool {.raises: [], gcsafe.} + StunPasswordProvider* = proc(username: seq[byte]): seq[byte] {.raises: [], gcsafe.} + + StunConn* = ref object + conn*: UdpConn # Underlying UDP connexion + laddr*: TransportAddress # Local address + raddr*: TransportAddress # Remote address + dataRecv*: AsyncQueue[seq[byte]] # data received which will be read by DTLS + stunMsgs*: AsyncQueue[seq[byte]] # stun messages received and to be + # processed by the stun message handler + handlesFut*: Future[void] # Stun Message handler + closeEvent: AsyncEvent + closed*: bool + + # Is ice-controlling and iceTiebreaker, not fully implemented yet. + iceControlling: bool + iceTiebreaker: uint32 + + # Specified by the user + usernameProvider: StunUsernameProvider + usernameChecker: StunUsernameChecker + passwordProvider: StunPasswordProvider + + rng: ref HmacDrbgContext + +# - Create Binding Messages (Request / Response / Error) +# Those procedures should be private. They're public for testing purpose. + +proc getBindingResponse*(self: StunConn, msg: StunMessage): StunMessage = + ## Creates a Binding Response StunMessage using a BindingRequest as input. + ## + result = StunMessage(msgType: StunBindingResponse, + transactionId: msg.transactionId) + result.attributes.add(XorMappedAddress.encode(self.raddr, msg.transactionId)) + +proc calculatePriority(self: StunConn): uint32 = + # https://datatracker.ietf.org/doc/html/rfc8445#section-5.1.2.1 + # Calculate Ice priority. At the moment, we assume we're a publicly available server. + let typePreference = 126'u32 + let localPreference = 65535'u32 + let componentID = 1'u32 + return (1 shl 24) * typePreference + (1 shl 8) * localPreference + (256 - componentID) + +proc getBindingRequest*(self: StunConn): StunMessage = + ## Creates a Binding Request. + ## + result = StunMessage(msgType: StunBindingRequest) + self.rng[].generate(result.transactionId) + + let username = self.usernameProvider() + if username != "": + result.attributes.add(UsernameAttribute.encode(username)) + + if self.iceControlling: + result.attributes.add(IceControlling.encode(self.iceTiebreaker)) + else: + result.attributes.add(IceControlled.encode(self.iceTiebreaker)) + result.attributes.add(Priority.encode(self.calculatePriority())) + +proc checkForError*(self: StunConn, msg: StunMessage): Option[StunMessage] = + # Check for error from a BindingRequest message. + # Returns an option with some BindingErrorResponse if there is an error. + # Returns none otherwise. + # https://datatracker.ietf.org/doc/html/rfc5389#section-10.1.2 + var res = StunMessage(msgType: StunBindingErrorResponse, + transactionId: msg.transactionId) + if msg.getAttribute(MessageIntegrity).isNone() or + msg.getAttribute(UsernameAttribute).isNone(): + res.attributes.add(ErrorCode.encode(ECBadRequest)) + return some(res) + + let usernameAttr = msg.getAttribute(UsernameAttribute).get() + if not self.usernameChecker(usernameAttr.username): + res.attributes.add(ErrorCode.encode(ECUnauthorized)) + return some(res) + + # https://datatracker.ietf.org/doc/html/rfc5389#section-15.9 + var unknownAttr: seq[uint16] + for attr in msg.attributes: + let typ = attr.attributeType + if typ.isRequired() and typ notin StunAttributeEnum: + unknownAttr.add(typ) + if unknownAttr.len() > 0: + res.attributes.add(ErrorCode.encode(ECUnknownAttribute)) + res.attributes.add(UnknownAttributes.encode(unknownAttr)) + return some(res) + + return none(StunMessage) + +proc isFingerprintValid*(msg: StunMessage): bool = + # Returns true if Fingerprint is missing or if it's valid. + # Returns false otherwise. + let fingerprint = msg.getAttribute(Fingerprint) + if fingerprint.isNone(): + return true + if msg.attributes[^1].attributeType != AttrFingerprint: + # Fingerprint should always be the last attribute. + return false + let + copyWithoutFingerprint = StunMessage( + msgType: msg.msgType, + transactionId: msg.transactionId, + attributes: msg.attributes[0 ..< ^1] + ) + encodedCopy = copyWithoutFingerprint.encode(@[]) + return fingerprint == StunMessage.decode(encodedCopy).getAttribute(Fingerprint) + +# - Stun Messages Handler - + +proc stunMessageHandler(self: StunConn) {.async: (raises: [CancelledError]).} = + # Read indefinitely Stun messages from stunMsgs queue. + # Sends a BindingResponse or BindingResponseError after receiving a BindingRequest. + while true: + let message = await self.stunMsgs.popFirst() + try: + let decoded = StunMessage.decode(await self.stunMsgs.popFirst()) + if not decoded.isFingerprintValid(): + # Fingerprint is invalid, the StunMessage received might be a false positive. + # Move this message to the `dataRecv` queue + await self.dataRecv.addLast(message) + continue + if decoded.msgType == StunBindingErrorResponse: + trace "Received a STUN error", decoded, remote = self.raddr + continue + elif decoded.msgType == StunBindingResponse: + # TODO: Process StunBindingResponse doesn't seem necessary for libp2p-webrtc-direct. + # Some browsers could be uncooperative. In that case, it should be implemented. + # It should be implemented for libp2p-webrtc. + continue + elif decoded.msgType == StunBindingRequest: + let errorOpt = self.checkForError(decoded) + if errorOpt.isSome(): + let error = errorOpt.get() + await self.conn.write(self.raddr, error.encode(@[])) + continue + + let + bindingResponse = self.getBindingResponse(decoded) + usernameAttr = decoded.getAttribute(UsernameAttribute).get() + password = self.passwordProvider(usernameAttr.username) + await self.conn.write( + self.raddr, + bindingResponse.encode(password) + ) + except SerializationError as exc: + debug "Failed to decode the Stun message", error=exc.msg, message + except WebRtcError as exc: + trace "Failed to write the Stun response", error=exc.msg + +proc new*( + T: type StunConn, + conn: UdpConn, + raddr: TransportAddress, + iceControlling: bool, + usernameProvider: StunUsernameProvider, + usernameChecker: StunUsernameChecker, + passwordProvider: StunPasswordProvider, + rng: ref HmacDrbgContext + ): T = + ## Initialize a Stun Connection + ## `conn` the underlying Udp Connection + ## `raddr` the remote address observed while receiving message with Udp + ## `iceControlling` flag to know if we're supposed to act as a "client" + ## (controlling) or a "server" (controlled) + ## `usernameProvider` callback to get a username for the Username attribute + ## `usernameChecker` callback to let the user check if the Username received + ## is valid or not + ## `passwordProvider` callback to get a key password for the + ## Message-integrity sha1 encryption + ## + var self = T( + conn: conn, + laddr: conn.laddr, + raddr: raddr, + closed: false, + closeEvent: newAsyncEvent(), + dataRecv: newAsyncQueue[seq[byte]](StunMaxQueuingMessages), + stunMsgs: newAsyncQueue[seq[byte]](StunMaxQueuingMessages), + iceControlling: iceControlling, + iceTiebreaker: rng[].generate(uint32), + usernameProvider: usernameProvider, + usernameChecker: usernameChecker, + passwordProvider: passwordProvider, + rng: rng + ) + self.handlesFut = self.stunMessageHandler() + return self + +proc join*(self: StunConn) {.async: (raises: [CancelledError]).} = + ## Wait for the Stun Connection to be closed + ## + await self.closeEvent.wait() + +proc close*(self: StunConn) = + ## Close a Stun Connection + ## + if self.closed: + debug "Try to close an already closed StunConn" + return + self.closeEvent.fire() + self.handlesFut.cancelSoon() + self.closed = true + +proc write*( + self: StunConn, + msg: seq[byte] + ) {.async: (raises: [CancelledError, WebRtcError]).} = + ## Write a message on Udp to a remote `raddr` using + ## the underlying Udp Connection + ## + if self.closed: + debug "Try to write on an already closed StunConn" + return + await self.conn.write(self.raddr, msg) + +proc read*(self: StunConn): Future[UdpPacketInfo] {.async: (raises: [CancelledError]).} = + ## Read the next received non-Stun Message + ## + if self.closed: + debug "Try to read on an already closed StunConn" + return + return (await self.dataRecv.popFirst(), self.raddr) diff --git a/webrtc/stun/stun_message.nim b/webrtc/stun/stun_message.nim new file mode 100644 index 0000000..6206bfe --- /dev/null +++ b/webrtc/stun/stun_message.nim @@ -0,0 +1,109 @@ +# Nim-WebRTC +# Copyright (c) 2024 Status Research & Development GmbH +# Licensed under either of +# * Apache License, version 2.0, ([LICENSE-APACHE](LICENSE-APACHE)) +# * MIT license ([LICENSE-MIT](LICENSE-MIT)) +# at your option. +# This file may not be copied, modified, or distributed except according to +# those terms. + +import bitops +import chronos, + bearssl, + chronicles, + binary_serialization +import stun_attributes, ../errors + +export binary_serialization + +logScope: + topics = "webrtc stun" + +const + StunMsgHeaderSize = 20 + StunMagicCookieSeq = @[ 0x21'u8, 0x12, 0xa4, 0x42 ] + StunMagicCookie = 0x2112a442 + +proc isStunMessage*(msg: seq[byte]): bool = + msg.len >= StunMsgHeaderSize and + msg[4..<8] == StunMagicCookieSeq and + bitand(0xC0'u8, msg[0]) == 0'u8 + +type +# Stun Header +# 0 1 2 3 +# 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 +# +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +# |0 0| STUN Message Type | Message Length | +# +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +# | Magic Cookie | +# +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +# | | +# | Transaction ID (96 bits) | +# | | +# +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +# Message type: +# 0x0001: Binding Request +# 0x0101: Binding Response +# 0x0111: Binding Error Response +# 0x0002: Shared Secret Request +# 0x0102: Shared Secret Response +# 0x0112: Shared Secret Error Response + + RawStunMessage = object + msgType: uint16 + length* {.bin_value: it.content.len().}: uint16 + magicCookie: uint32 + transactionId: array[12, byte] # Down from 16 to 12 bytes in RFC5389 + content* {.bin_len: it.length.}: seq[byte] + + StunMessage* = object + msgType*: uint16 + transactionId*: array[12, byte] + attributes*: seq[RawStunAttribute] + +proc getAttribute*[T](self: StunMessage, typ: typedesc[T]): Option[T] = + let attributeTyp = attributeType(typ) + for attr in self.attributes: + if attr.attributeType == attributeTyp.uint16: + return some(T.decode(attr)) + return none(T) + +proc addLength(msgEncoded: var seq[byte], toAddLength: uint16) = + # Add length to an already encoded message. It is necessary because + # some attributes (such as Message Integrity or Fingerprint) need + # the encoded message to be computed. + let + currentLength: uint16 = msgEncoded[2].uint16 * 256'u16 + msgEncoded[3].uint16 + totalLength = currentLength + toAddLength + if totalLength < currentLength: + raise newException(WebRtcError, "Stun - Try to encode a message larger than uint16 max") + msgEncoded[2] = (totalLength div 256'u16).uint8 + msgEncoded[3] = (totalLength mod 256'u16).uint8 + +proc decode*(T: typedesc[StunMessage], msg: seq[byte]): StunMessage = + let smi = Binary.decode(msg, RawStunMessage) + return T(msgType: smi.msgType, + transactionId: smi.transactionId, + attributes: RawStunAttribute.decode(smi.content)) + +proc encode*( + msg: StunMessage, + messageIntegrityPassword: seq[byte] + ): seq[byte] = + const pad = @[0, 3, 2, 1] + var smi = RawStunMessage(msgType: msg.msgType, + magicCookie: StunMagicCookie, + transactionId: msg.transactionId) + for attr in msg.attributes: + smi.content.add(Binary.encode(attr)) + smi.content.add(newSeq[byte](pad[smi.content.len() mod 4])) + + result = Binary.encode(smi) + + if messageIntegrityPassword != @[]: + result.addLength(24) + result.add(Binary.encode(MessageIntegrity.encode(result, messageIntegrityPassword))) + + result.addLength(8) + result.add(Binary.encode(Fingerprint.encode(result))) diff --git a/webrtc/stun/stun_transport.nim b/webrtc/stun/stun_transport.nim new file mode 100644 index 0000000..6813ecf --- /dev/null +++ b/webrtc/stun/stun_transport.nim @@ -0,0 +1,116 @@ +# Nim-WebRTC +# Copyright (c) 2024 Status Research & Development GmbH +# Licensed under either of +# * Apache License, version 2.0, ([LICENSE-APACHE](LICENSE-APACHE)) +# * MIT license ([LICENSE-MIT](LICENSE-MIT)) +# at your option. +# This file may not be copied, modified, or distributed except according to +# those terms. + +import tables +import chronos, chronicles, bearssl +import stun_connection, stun_message, ../udp_connection + +logScope: + topics = "webrtc stun stun_transport" + +const + StunMaxPendingConnections = 512 + +type + Stun* = ref object + connections: Table[TransportAddress, StunConn] + pendingConn: AsyncQueue[StunConn] + readingLoop: Future[void] + conn: UdpConn + + usernameProvider: StunUsernameProvider + usernameChecker: StunUsernameChecker + passwordProvider: StunPasswordProvider + + rng: ref HmacDrbgContext + +proc accept*(self: Stun): Future[StunConn] {.async: (raises: [CancelledError]).} = + ## Accept a Stun Connection + ## + var res: StunConn + while true: + res = await self.pendingConn.popFirst() + if res.closed != true: # The connection could be closed before being accepted + break + return res + +proc connect*( + self: Stun, + raddr: TransportAddress + ): Future[StunConn] {.async: (raises: []).} = + ## Connect to a remote address, creating a Stun Connection + ## + self.connections.withValue(raddr, res): + return res[] + do: + let res = StunConn.new(self.conn, raddr, false, self.usernameProvider, + self.usernameChecker, self.passwordProvider, self.rng) + self.connections[raddr] = res + return res + +proc cleanupStunConn(self: Stun, conn: StunConn) {.async: (raises: []).} = + # Waiting for a connection to be closed to remove it from the table + try: + await conn.join() + self.connections.del(conn.raddr) + except CancelledError as exc: + warn "Error cleaning up Stun Connection", error=exc.msg + +proc stunReadLoop(self: Stun) {.async: (raises: [CancelledError]).} = + while true: + let (buf, raddr) = await self.conn.read() + var stunConn: StunConn + if not self.connections.hasKey(raddr): + stunConn = StunConn.new(self.conn, raddr, true, self.usernameProvider, + self.usernameChecker, self.passwordProvider, self.rng) + self.connections[raddr] = stunConn + await self.pendingConn.addLast(stunConn) + asyncSpawn self.cleanupStunConn(stunConn) + else: + try: + stunConn = self.connections[raddr] + except KeyError as exc: + doAssert(false, "Should never happen") + + if isStunMessage(buf): + await stunConn.stunMsgs.addLast(buf) + else: + await stunConn.dataRecv.addLast(buf) + +proc stop(self: Stun) = + ## Stop the Stun transport and close all the connections + ## + for conn in self.connections.values(): + conn.close() + self.readingLoop.cancelSoon() + +proc defaultUsernameProvider(): string = "" +proc defaultUsernameChecker(username: seq[byte]): bool = true +proc defaultPasswordProvider(username: seq[byte]): seq[byte] = @[] + +proc new*( + T: type Stun, + conn: UdpConn, + usernameProvider: StunUsernameProvider = defaultUsernameProvider, + usernameChecker: StunUsernameChecker = defaultUsernameChecker, + passwordProvider: StunPasswordProvider = defaultPasswordProvider, + rng: ref HmacDrbgContext = HmacDrbgContext.new(), + ): T = + ## Initialize the Stun transport + ## + var self = T( + conn: conn, + usernameProvider: usernameProvider, + usernameChecker: usernameChecker, + passwordProvider: passwordProvider, + rng: rng + ) + self.readingLoop = stunReadLoop() + self.pendingConn = newAsyncQueue[StunConn](StunMaxPendingConnections) + return self diff --git a/webrtc/stun/stun_utils.nim b/webrtc/stun/stun_utils.nim new file mode 100644 index 0000000..cd222b5 --- /dev/null +++ b/webrtc/stun/stun_utils.nim @@ -0,0 +1,57 @@ +# Nim-WebRTC +# Copyright (c) 2024 Status Research & Development GmbH +# Licensed under either of +# * Apache License, version 2.0, ([LICENSE-APACHE](LICENSE-APACHE)) +# * MIT license ([LICENSE-MIT](LICENSE-MIT)) +# at your option. +# This file may not be copied, modified, or distributed except according to +# those terms. + +import sequtils, typetraits, std/sha1 +import bearssl + +proc generateRandomSeq*(rng: ref HmacDrbgContext, size: int): seq[byte] = + result = newSeq[byte](size) + rng[].generate(result) + +proc createCrc32Table(): array[0..255, uint32] = + for i in 0..255: + var rem = i.uint32 + for j in 0..7: + if (rem and 1) > 0: + rem = (rem shr 1) xor 0xedb88320'u32 + else: + rem = rem shr 1 + result[i] = rem + +proc crc32*(s: seq[byte]): uint32 = + # CRC-32 is used for the fingerprint attribute + # See https://datatracker.ietf.org/doc/html/rfc5389#section-15.5 + const crc32table = createCrc32Table() + result = 0xffffffff'u32 + for c in s: + result = (result shr 8) xor crc32table[(result and 0xff) xor c] + result = not result + +proc hmacSha1*(key: seq[byte], msg: seq[byte]): seq[byte] = + # HMAC-SHA1 is used for the message integrity attribute + # See https://datatracker.ietf.org/doc/html/rfc5389#section-15.4 + let + keyPadded = + if len(key) > 64: + @(secureHash(key.mapIt(it.chr)).distinctBase) + elif key.len() < 64: + key.concat(newSeq[byte](64 - key.len())) + else: + key + innerHash = keyPadded. + mapIt(it xor 0x36'u8). + concat(msg). + mapIt(it.chr). + secureHash() + outerHash = keyPadded. + mapIt(it xor 0x5c'u8). + concat(@(innerHash.distinctBase)). + mapIt(it.chr). + secureHash() + return @(outerHash.distinctBase) diff --git a/webrtc/udp_connection.nim b/webrtc/udp_connection.nim index 10ed0c6..d6c126d 100644 --- a/webrtc/udp_connection.nim +++ b/webrtc/udp_connection.nim @@ -18,8 +18,6 @@ logScope: # the remote address used by the underlying protocols (dtls/sctp etc...) type - WebRtcUdpError = object of WebRtcError - UdpPacketInfo* = tuple message: seq[byte] raddr: TransportAddress @@ -42,7 +40,6 @@ proc init*(T: type UdpConn, laddr: TransportAddress): T = # On receive Udp message callback, store the # message with the corresponding remote address try: - trace "UDP onReceive" let msg = udp.getMessage() self.dataRecv.addLastNoWait((msg, raddr)) except CatchableError as exc: @@ -65,7 +62,7 @@ proc write*( self: UdpConn, raddr: TransportAddress, msg: seq[byte] - ) {.async: (raises: [CancelledError, WebRtcUdpError]).} = + ) {.async: (raises: [CancelledError, WebRtcError]).} = ## Write a message on Udp to a remote address `raddr` ## if self.closed: @@ -75,8 +72,8 @@ proc write*( try: await self.udp.sendTo(raddr, msg) except TransportError as exc: - raise newException(WebRtcUdpError, - "Error when sending data on a DatagramTransport: " & exc.msg , exc) + raise newException(WebRtcError, + "UDP - Error when sending data on a DatagramTransport: " & exc.msg , exc) proc read*(self: UdpConn): Future[UdpPacketInfo] {.async: (raises: [CancelledError]).} = ## Read the next received Udp message