// _____ _ // |_ _| |_ _ _ ___ ___ _ __ __ _ // | | | ' \| '_/ -_) -_) ' \/ _` |_ // |_| |_||_|_| \___\___|_|_|_\__,_(_) // // Threema iOS Client // Copyright (c) 2020 Threema GmbH // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License, version 3, // as published by the Free Software Foundation. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . import Foundation import CocoaLumberjackSwift public class VoIPCallSdpPatcher: NSObject { static let SDP_MEDIA_AUDIO_ANY_RE = "m=audio ([^ ]+) ([^ ]+) (.+)" static let SDP_RTPMAP_OPUS_RE = "a=rtpmap:([^ ]+) opus.*" static let SDP_RTPMAP_ANY_RE = "a=rtpmap:([^ ]+) .*" static let SDP_FMTP_ANY_RE = "a=fmtp:([^ ]+) ([^ ]+)" static let SDP_EXTMAP_ANY_RE = "a=extmap:[^ ]+ (.*)" convenience init(_ config: RtpHeaderExtensionConfig) { self.init() rtpHeaderExtensionConfig = config } /// Whether this SDP is created locally and it is the offer, a local answer or a remote SDP. public enum SdpType { case LOCAL_OFFER case LOCAL_ANSWER_OR_REMOTE_SDP } /// RTP header extension configuration. public enum RtpHeaderExtensionConfig { case DISABLE case ENABLE_WITH_LEGACY_ONE_BYTE_HEADER_ONLY case ENABLE_WITH_ONE_AND_TWO_BYTE_HEADER } enum SdpErrorType: Error, Equatable { case invalidSdp case illegalArgument case matchesError case unknownSection } internal enum SdpSection: String { case GLOBAL case MEDIA_AUDIO case MEDIA_VIDEO case MEDIA_DATA_CHANNEL case MEDIA_UNKNOWN func isRtpSection() -> Bool { switch self { case .GLOBAL, .MEDIA_DATA_CHANNEL, .MEDIA_UNKNOWN: return false case .MEDIA_AUDIO, .MEDIA_VIDEO: return true } } } internal enum LineAction { case ACCEPT case REJECT case REWRITE } class SdpError: Error { var type: SdpErrorType var description: String init(type: SdpErrorType, description: String) { self.type = type self.description = description } var errorDescription: String? { get { return self.description } } } private var rtpHeaderExtensionConfig: RtpHeaderExtensionConfig = .DISABLE internal struct SdpPatcherContext { internal var type: SdpType internal var config: VoIPCallSdpPatcher internal var payloadTypeOpus: String internal var rtpExtensionIdRemapper: RtpExtensionIdRemapper internal var section: SdpSection init(type: SdpType, config: VoIPCallSdpPatcher, payloadTypeOpus: String) { self.type = type self.config = config self.payloadTypeOpus = payloadTypeOpus self.rtpExtensionIdRemapper = RtpExtensionIdRemapper(config: config) self.section = SdpSection.GLOBAL } } internal struct Line { private(set) var line: String private var action: LineAction? init(line: String) { self.line = line } mutating func accept() throws -> LineAction { if action != nil { throw SdpError(type: .illegalArgument, description: "LineAction.action already set") } self.action = .ACCEPT return self.action! } mutating func reject() throws -> LineAction { if action != nil { throw SdpError(type: .illegalArgument, description: "LineAction.action already set") } self.action = .REJECT return self.action! } mutating func rewrite(line: String) throws -> LineAction { if action != nil { throw SdpError(type: .illegalArgument, description: "LineAction.action already set") } self.action = .REWRITE self.line = line return self.action! } } internal struct RtpExtensionIdRemapper { private var currentId: Int? private var maxId: Int? private var extensionIdMap = [String: Int]() init(config: VoIPCallSdpPatcher) { currentId = 0 switch config.rtpHeaderExtensionConfig { case .ENABLE_WITH_LEGACY_ONE_BYTE_HEADER_ONLY: maxId = 14 case .ENABLE_WITH_ONE_AND_TWO_BYTE_HEADER: maxId = 255 default: maxId = 0 } } mutating func assignId(uriAndAttributes: String) throws -> Int { // It is extremely important that we give extensions with the same URI the same ID // across different media sections, otherwise the bundling mechanism will fail and we // get all sorts of weird behaviour from the WebRTC stack. var id = extensionIdMap[uriAndAttributes] if id == nil { // Check if exhausted currentId! += 1 if currentId! > maxId! { throw SdpError(type: .invalidSdp, description: "RTP extension IDs exhausted") } id = currentId if currentId == 15 { currentId! += 1 id! += 1 } extensionIdMap[uriAndAttributes] = id } return id! } } /// Patch an SDP offer / answer with a few things that we want to enforce in Threema: /// For all media lines: /// - Remove audio level and frame marking header extensions /// - Remap extmap IDs (when offering) /// /// For audio in specific: /// - Only support Opus, remove all other codecs /// - Force CBR /// /// The use of CBR (constant bit rate) will also suppress VAD (voice activity detection). For /// more security considerations regarding codec configuration, see RFC 6562: /// https://tools.ietf.org/html/rfc6562 /// /// - Parameters: /// - type: Type /// - sdp: String /// - Throws: SdpError /// - Returns: Updated sdp func patch(type: SdpType, sdp: String) throws -> String { var payloadTypeOpus: String? do { let sdpRtpmapOpusRegex = try NSRegularExpression.init(pattern: VoIPCallSdpPatcher.SDP_RTPMAP_OPUS_RE, options: []) let sdpRange = NSRange(sdp.startIndex.. index { if !newLine.starts(with: "m=") { debug.append(newLine) index = i } else { break } } } DDLogError(String(format: "Rejected section: %@", debug)) } } /// Handle a section line. /// - Parameters: /// - context: SdpPatcherContext /// - line: Line /// - Throws: SdpError /// - Returns: LineAction private func handleSectionLine(context: inout SdpPatcherContext, line: inout Line) throws -> LineAction { let lineString = line.line // Audio section do { let sectionRegex = try NSRegularExpression(pattern: VoIPCallSdpPatcher.SDP_MEDIA_AUDIO_ANY_RE, options: []) let lineRange = NSRange(lineString.startIndex.. LineAction { return try handleRtpAttributes(context, &line) } // Handle RTP attributes shared across global (non-media) and media sections. /// - Returns: LineAction /// - Parameters: /// - context: SdpPatcherContext /// - line: Line /// - Throws: SdpError private func handleRtpAttributes(_ context: SdpPatcherContext, _ line: inout Line) throws -> LineAction { let lineString = line.line // Reject one-/two-byte RTP header mixed mode, if requested if context.config.rtpHeaderExtensionConfig != .ENABLE_WITH_ONE_AND_TWO_BYTE_HEADER && lineString.starts(with: "a=extmap-allow-mixed") { return try line.reject() } // Accept the rest return try line.accept() } /// Handle audio section line. /// - Parameters: /// - context: SdpPatcherContext /// - line: Line /// - Throws: SdpError /// - Returns: LineAction private func handleAudioLine(_ context: inout SdpPatcherContext, _ line: inout Line) throws -> LineAction { let lineString = line.line let lineRange = NSRange(lineString.startIndex.. LineAction { let lineString = line.line let lineRange = NSRange(lineString.startIndex.. LineAction { return try line.accept() } /// Handle Rtp header extensions. /// - Parameters: /// - context: SdpPatcherContext /// - line: Line /// - uriAndAttributes: String /// - Throws: SdpError /// - Returns: LineAction private func handleRtpHeaderExtensionLine(_ context: inout SdpPatcherContext, _ line: inout Line, _ uriAndAttributes: String) throws -> LineAction { // Always reject if disabled if context.config.rtpHeaderExtensionConfig == .DISABLE { return try line.reject() } // Always reject some of the header extensions if uriAndAttributes.contains("urn:ietf:params:rtp-hdrext:ssrc-audio-level") || // Audio level, only useful for SFU use cases, remove uriAndAttributes.contains("urn:ietf:params:rtp-hdrext:csrc-audio-level") || uriAndAttributes.contains("http://tools.ietf.org/html/draft-ietf-avtext-framemarking-07") { // Frame marking, only useful for SFU use cases, remove return try line.reject() } // Require encryption for the remainder of headers if uriAndAttributes.starts(with: "urn:ietf:params:rtp-hdrext:encrypt") { return try remapRtpHeaderExtensionIfOutbound(&context, &line, uriAndAttributes) } // Reject the rest return try line.reject() } /// Handle remap Rtp header extension if outbound. /// - Parameters: /// - context: SdpPatcherContext /// - line: Line /// - uriAndAttributes: String /// - Throws: SdpError /// - Returns: LineAction private func remapRtpHeaderExtensionIfOutbound(_ context: inout SdpPatcherContext, _ line: inout Line, _ uriAndAttributes: String) throws -> LineAction { // Rewrite if local offer, otherwise accept if context.type == .LOCAL_OFFER { return try line.rewrite(line: String(format: "a=extmap:%i %@", context.rtpExtensionIdRemapper.assignId(uriAndAttributes: uriAndAttributes), uriAndAttributes)) } else { return try line.accept() } } } extension String { var linesArray:[String] { var result:[String] = [] enumerateLines { (line, _) -> () in result.append(line) } return result } }