// _____ _
// |_ _| |_ _ _ ___ ___ _ __ __ _
// | | | ' \| '_/ -_) -_) ' \/ _` |_
// |_| |_||_|_| \___\___|_|_|_\__,_(_)
//
// 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
}
}