From 3e245b1ae3745605915e118fabd6675b61845ef4 Mon Sep 17 00:00:00 2001 From: samuelzielke Date: Wed, 21 Oct 2026 19:07:55 +0200 Subject: [PATCH] feat(intercom): DTLS 1.2 server with use_srtp, verified against OpenSSL Single cipher suite (ECDHE-ECDSA-AES128-GCM-SHA256), client certificate checked against the SDP fingerprint, extended master secret. Co-Authored-By: Claude Opus 5.5 --- .../MusubiKit/Sources/Intercom/RTC/DTLS.swift | 420 ++++++++++++++++++ .../IntercomTests/DTLSInteropTests.swift | 114 +++++ 2 files changed, 534 insertions(+) create mode 100644 Packages/MusubiKit/Sources/Intercom/RTC/DTLS.swift create mode 100644 Packages/MusubiKit/Tests/IntercomTests/DTLSInteropTests.swift diff --git a/Packages/MusubiKit/Sources/Intercom/RTC/DTLS.swift b/Packages/MusubiKit/Sources/Intercom/RTC/DTLS.swift new file mode 100644 index 0000000..69e88f1 --- /dev/null +++ b/Packages/MusubiKit/Sources/Intercom/RTC/DTLS.swift @@ -0,0 +1,420 @@ +import CryptoKit +import Foundation + +/// Keying material for SRTP, exported from DTLS (RFC 5764 4.2). +struct SRTPKeyMaterial: Sendable, Equatable { + let clientKey: [UInt8] + let serverKey: [UInt8] + let clientSalt: [UInt8] + let serverSalt: [UInt8] +} + +/// A DTLS 1.2 server for WebRTC, deliberately narrow: +/// one cipher suite (TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256), P-256, ECDSA client certificates +/// checked against the SDP fingerprint, the use_srtp extension with SRTP_AES128_CM_HMAC_SHA1_80 +/// and extended master secret. No resumption, no renegotiation. +/// Synchronous state machine: feed datagrams, send what comes back. Not thread-safe. +final class DTLSServer { + enum State: Equatable { + case awaitingClientHello + case awaitingClientFlight + case established + case closed + case failed(String) + } + + struct Failure: Error, CustomStringConvertible { + let description: String + let alert: UInt8 + init(_ description: String, alert: UInt8 = 40) { self.description = description; self.alert = alert } + } + + private enum ContentType: UInt8 { case changeCipherSpec = 20, alert = 21, handshake = 22, applicationData = 23 } + private enum HandshakeType: UInt8 { + case clientHello = 1, serverHello = 2, certificate = 11, serverKeyExchange = 12, certificateRequest = 13 + case serverHelloDone = 14, certificateVerify = 15, clientKeyExchange = 16, finished = 20 + } + + static let cipherSuite: UInt16 = 0xC02B + static let srtpProfile: UInt16 = 0x0001 + private static let version: UInt16 = 0xFEFD + private static let ecdsaSecp256r1SHA256: UInt16 = 0x0403 + + private(set) var state: State = .awaitingClientHello + private(set) var keyMaterial: SRTPKeyMaterial? + + private let certificate: DTLSCertificate + private let expectedFingerprint: String? + + private var transcript: [UInt8] = [] + private var clientRandom: [UInt8] = [] + private var serverRandom: [UInt8] = [] + private var usesExtendedMasterSecret = false + private var echoRenegotiationInfo = false + private var echoPointFormats = false + private var ephemeral = P256.KeyAgreement.PrivateKey() + private var masterSecret: [UInt8] = [] + private var clientPublicKey: P256.Signing.PublicKey? + + private var readCipher: (key: SymmetricKey, iv: [UInt8])? + private var writeCipher: (key: SymmetricKey, iv: [UInt8])? + private var writeEpoch: UInt16 = 0 + private var writeSequence: [UInt16: UInt64] = [:] + private var nextSendMessageSeq: UInt16 = 0 + private var nextReceiveMessageSeq: UInt16 = 0 + private var fragments: [UInt16: (type: UInt8, body: [UInt8?])] = [:] + private var lastFlight: [(type: ContentType, epoch: UInt16, body: [UInt8])] = [] + private var flightTrigger: UInt16 = 0 + + /// `expectedFingerprint`: the peer's `a=fingerprint:sha-256` from the SDP offer. + init(certificate: DTLSCertificate, expectedFingerprint: String?) { + self.certificate = certificate + self.expectedFingerprint = expectedFingerprint?.uppercased() + } + + /// RFC 7983 demultiplexing: DTLS content types 20…63. + static func looksLikeDTLS(_ packet: [UInt8]) -> Bool { + guard let first = packet.first else { return false } + return first >= 20 && first <= 63 + } + + /// Processes one datagram and returns the datagrams to send back. + func handle(_ datagram: [UInt8]) -> [[UInt8]] { + switch state { + case .closed, .failed: return [] + default: break + } + do { + var output: [[UInt8]] = [] + var reader = ByteReader(datagram) + while reader.remaining >= 13 { + let type = try reader.u8() + _ = try reader.u16() + let epoch = try reader.u16() + let sequence = try reader.u48() + let fragment = try reader.take(Int(try reader.u16())) + guard let content = ContentType(rawValue: type) else { continue } + let plain: [UInt8] + if epoch == 0 { + plain = fragment + } else if epoch == 1, readCipher != nil { + guard let decrypted = decrypt(fragment, type: type, epoch: epoch, sequence: sequence) else { continue } + plain = decrypted + } else { + continue + } + output += try process(content, epoch: epoch, plain) + } + return output + } catch let failure as Failure { + state = .failed(failure.description) + return [record(.alert, epoch: writeEpoch, body: [2, failure.alert])] + } catch { + state = .failed("Malformed DTLS record") + return [record(.alert, epoch: writeEpoch, body: [2, 50])] + } + } + + // MARK: - Records + + private func process(_ type: ContentType, epoch: UInt16, _ body: [UInt8]) throws -> [[UInt8]] { + switch type { + case .handshake: + return try receiveHandshake(body, epoch: epoch) + case .changeCipherSpec: + if state == .awaitingClientFlight, readCipher == nil, !masterSecret.isEmpty { + readCipher = keys().client + } + return [] + case .alert: + if body.count >= 2, body[1] == 0 { state = .closed } else if body.first == 2 { state = .failed("Peer alert \(body.last ?? 0)") } + return [] + case .applicationData: + return [] + } + } + + private func receiveHandshake(_ body: [UInt8], epoch: UInt16) throws -> [[UInt8]] { + var reader = ByteReader(body) + var output: [[UInt8]] = [] + while reader.remaining >= 12 { + let type = try reader.u8() + let length = try reader.u24() + let messageSeq = try reader.u16() + let offset = try reader.u24() + let fragmentLength = try reader.u24() + let fragment = try reader.take(fragmentLength) + guard length <= 1 << 16, offset + fragmentLength <= length else { throw Failure("Bad fragment", alert: 50) } + + if messageSeq < nextReceiveMessageSeq { + // Retransmitted flight: the peer missed ours. + if messageSeq == flightTrigger, !lastFlight.isEmpty { output += resendLastFlight() } + continue + } + var entry = fragments[messageSeq] ?? (type, [UInt8?](repeating: nil, count: length)) + guard entry.type == type, entry.body.count == length else { throw Failure("Inconsistent fragments", alert: 50) } + for i in 0.. [[UInt8]] { + let full = handshakeHeader(type: type, length: body.count, messageSeq: messageSeq) + body + switch (state, HandshakeType(rawValue: type)) { + case (.awaitingClientHello, .clientHello): + try parseClientHello(body) + transcript += full + flightTrigger = messageSeq + return sendServerFlight() + + case (.awaitingClientFlight, .certificate): + var r = ByteReader(body) + var list = ByteReader(try r.vector(3)) + guard !list.isAtEnd else { throw Failure("Client sent no certificate", alert: 42) } + let der = try list.vector(3) + if let expectedFingerprint, DTLSCertificate.fingerprint(der) != expectedFingerprint { + throw Failure("Client certificate does not match the SDP fingerprint", alert: 42) + } + guard let spki = DER.subjectPublicKeyInfo(certificate: der), + let key = try? P256.Signing.PublicKey(derRepresentation: spki) else { + throw Failure("Unsupported client certificate", alert: 43) + } + clientPublicKey = key + transcript += full + + case (.awaitingClientFlight, .clientKeyExchange): + var r = ByteReader(body) + let point = try r.vector(1) + guard let peer = try? P256.KeyAgreement.PublicKey(x963Representation: point), + let shared = try? ephemeral.sharedSecretFromKeyAgreement(with: peer) else { + throw Failure("Bad client key share", alert: 47) + } + let preMaster = shared.withUnsafeBytes { Array($0) } + transcript += full + if usesExtendedMasterSecret { + masterSecret = TLSPRF.compute(secret: preMaster, label: "extended master secret", + seed: Array(SHA256.hash(data: transcript)), length: 48) + } else { + masterSecret = TLSPRF.compute(secret: preMaster, label: "master secret", + seed: clientRandom + serverRandom, length: 48) + } + + case (.awaitingClientFlight, .certificateVerify): + guard let clientPublicKey, !masterSecret.isEmpty else { throw Failure("Unexpected CertificateVerify", alert: 10) } + var r = ByteReader(body) + guard try r.u16() == Self.ecdsaSecp256r1SHA256 else { throw Failure("Unsupported signature algorithm", alert: 40) } + let signature = try r.vector(2) + guard let parsed = try? P256.Signing.ECDSASignature(derRepresentation: signature), + clientPublicKey.isValidSignature(parsed, for: transcript) else { + throw Failure("CertificateVerify failed", alert: 51) + } + transcript += full + + case (.awaitingClientFlight, .finished): + guard epoch == 1, clientPublicKey != nil else { throw Failure("Finished before authentication", alert: 10) } + let expected = TLSPRF.compute(secret: masterSecret, label: "client finished", + seed: Array(SHA256.hash(data: transcript)), length: 12) + guard constantTimeEqual(body, expected) else { throw Failure("Client Finished does not verify", alert: 51) } + transcript += full + flightTrigger = messageSeq + let output = sendFinalFlight() + exportKeys() + state = .established + return output + + default: + throw Failure("Unexpected handshake message \(type)", alert: 10) + } + return [] + } + + // MARK: - ClientHello + + private func parseClientHello(_ body: [UInt8]) throws { + var r = ByteReader(body) + guard try r.u16() == Self.version else { throw Failure("Only DTLS 1.2 is supported", alert: 70) } + clientRandom = try r.take(32) + _ = try r.vector(1) // session id + _ = try r.vector(1) // cookie + var suites = ByteReader(try r.vector(2)) + var offersSuite = false + while suites.remaining >= 2 { + let suite = try suites.u16() + if suite == Self.cipherSuite { offersSuite = true } + if suite == 0x00FF { echoRenegotiationInfo = true } + } + guard offersSuite else { throw Failure("Client does not offer ECDHE-ECDSA-AES128-GCM-SHA256") } + _ = try r.vector(1) // compression methods + + var offersSRTP = false + var offersP256 = false + if !r.isAtEnd { + var extensions = ByteReader(try r.vector(2)) + while extensions.remaining >= 4 { + let type = try extensions.u16() + var data = ByteReader(try extensions.vector(2)) + switch type { + case 0x000E: // use_srtp + var profiles = ByteReader(try data.vector(2)) + while profiles.remaining >= 2 { if try profiles.u16() == Self.srtpProfile { offersSRTP = true } } + case 0x000A: // supported_groups + var groups = ByteReader(try data.vector(2)) + while groups.remaining >= 2 { if try groups.u16() == 23 { offersP256 = true } } + case 0x0017: usesExtendedMasterSecret = true + case 0xFF01: echoRenegotiationInfo = true + case 0x000B: echoPointFormats = true + default: break + } + } + } else { + offersP256 = true // no supported_groups extension means "any" + } + guard offersSRTP else { throw Failure("Client does not offer SRTP_AES128_CM_HMAC_SHA1_80") } + guard offersP256 else { throw Failure("Client does not offer P-256") } + } + + // MARK: - Server flights + + private func sendServerFlight() -> [[UInt8]] { + serverRandom = (0..<32).map { _ in UInt8.random(in: 0...255) } + + var hello = ByteWriter() + hello.u16(Self.version) + hello.append(serverRandom) + hello.u8(0) // session id + hello.u16(Self.cipherSuite) + hello.u8(0) // no compression + hello.vector(2) { ext in + if echoRenegotiationInfo { ext.u16(0xFF01); ext.u16(1); ext.u8(0) } + if usesExtendedMasterSecret { ext.u16(0x0017); ext.u16(0) } + if echoPointFormats { ext.u16(0x000B); ext.u16(2); ext.u8(1); ext.u8(0) } + ext.u16(0x000E); ext.u16(5); ext.u16(2); ext.u16(Self.srtpProfile); ext.u8(0) + } + + var cert = ByteWriter() + cert.vector(3) { list in list.vector(3) { $0.append(certificate.der) } } + + var params = ByteWriter() + params.u8(3) // named_curve + params.u16(23) // secp256r1 + params.vector(1) { $0.append(ephemeral.publicKey.x963Representation) } + let signed = (try? certificate.privateKey.signature(for: clientRandom + serverRandom + params.bytes).derRepresentation) ?? Data() + var keyExchange = params + keyExchange.u16(Self.ecdsaSecp256r1SHA256) + keyExchange.vector(2) { $0.append(signed) } + + var request = ByteWriter() + request.vector(1) { $0.u8(64) } // ecdsa_sign + request.vector(2) { $0.u16(Self.ecdsaSecp256r1SHA256) } + request.u16(0) // no CA names + + lastFlight = [] + for (type, body) in [(HandshakeType.serverHello, hello.bytes), (.certificate, cert.bytes), + (.serverKeyExchange, keyExchange.bytes), (.certificateRequest, request.bytes), + (.serverHelloDone, [])] { + let message = handshakeHeader(type: type.rawValue, length: body.count, messageSeq: nextSendMessageSeq) + body + nextSendMessageSeq += 1 + transcript += message + lastFlight.append((.handshake, 0, message)) + } + state = .awaitingClientFlight + return resendLastFlight() + } + + private func sendFinalFlight() -> [[UInt8]] { + let verify = TLSPRF.compute(secret: masterSecret, label: "server finished", + seed: Array(SHA256.hash(data: transcript)), length: 12) + let finished = handshakeHeader(type: HandshakeType.finished.rawValue, length: 12, messageSeq: nextSendMessageSeq) + verify + nextSendMessageSeq += 1 + transcript += finished + writeCipher = keys().server + lastFlight = [(.changeCipherSpec, 0, [1]), (.handshake, 1, finished)] + return resendLastFlight() + } + + /// Sends the last flight again with fresh record sequence numbers, all in one datagram. + private func resendLastFlight() -> [[UInt8]] { + [lastFlight.flatMap { record($0.type, epoch: $0.epoch, body: $0.body) }] + } + + private func handshakeHeader(type: UInt8, length: Int, messageSeq: UInt16) -> [UInt8] { + var w = ByteWriter(capacity: 12) + w.u8(type) + w.u24(length) + w.u16(messageSeq) + w.u24(0) + w.u24(length) + return w.bytes + } + + private func record(_ type: ContentType, epoch: UInt16, body: [UInt8]) -> [UInt8] { + let sequence = writeSequence[epoch, default: 0] + writeSequence[epoch] = sequence + 1 + let payload = epoch == 0 ? body : encrypt(body, type: type.rawValue, epoch: epoch, sequence: sequence) + var w = ByteWriter(capacity: 13 + payload.count) + w.u8(type.rawValue) + w.u16(Self.version) + w.u16(epoch) + w.u48(sequence) + w.u16(UInt16(payload.count)) + w.append(payload) + return w.bytes + } + + // MARK: - Keys and AEAD + + private func keys() -> (client: (key: SymmetricKey, iv: [UInt8]), server: (key: SymmetricKey, iv: [UInt8])) { + let block = TLSPRF.compute(secret: masterSecret, label: "key expansion", seed: serverRandom + clientRandom, length: 40) + return ((SymmetricKey(data: block[0..<16]), Array(block[32..<36])), + (SymmetricKey(data: block[16..<32]), Array(block[36..<40]))) + } + + private func exportKeys() { + let m = TLSPRF.compute(secret: masterSecret, label: "EXTRACTOR-dtls_srtp", seed: clientRandom + serverRandom, length: 60) + keyMaterial = SRTPKeyMaterial(clientKey: Array(m[0..<16]), serverKey: Array(m[16..<32]), + clientSalt: Array(m[32..<46]), serverSalt: Array(m[46..<60])) + } + + private func additionalData(type: UInt8, epoch: UInt16, sequence: UInt64, length: Int) -> [UInt8] { + var w = ByteWriter(capacity: 13) + w.u16(epoch) + w.u48(sequence) + w.u8(type) + w.u16(Self.version) + w.u16(UInt16(length)) + return w.bytes + } + + private func encrypt(_ plain: [UInt8], type: UInt8, epoch: UInt16, sequence: UInt64) -> [UInt8] { + guard let writeCipher else { return plain } + var explicit = ByteWriter(capacity: 8) + explicit.u16(epoch) + explicit.u48(sequence) + guard let nonce = try? AES.GCM.Nonce(data: writeCipher.iv + explicit.bytes), + let sealed = try? AES.GCM.seal(plain, using: writeCipher.key, nonce: nonce, + authenticating: additionalData(type: type, epoch: epoch, sequence: sequence, length: plain.count)) + else { return [] } + return explicit.bytes + Array(sealed.ciphertext) + Array(sealed.tag) + } + + private func decrypt(_ fragment: [UInt8], type: UInt8, epoch: UInt16, sequence: UInt64) -> [UInt8]? { + guard let readCipher, fragment.count >= 8 + 16 else { return nil } + let explicit = Array(fragment[0..<8]) + let ciphertext = Array(fragment[8...size) + _ = withUnsafePointer(to: &address) { $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { bind(socket, $0, length) } } + _ = withUnsafeMutablePointer(to: &address) { $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { getsockname(socket, $0, &length) } } + var timeout = timeval(tv_sec: 0, tv_usec: 200_000) + setsockopt(socket, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout.size)) + let port = UInt16(bigEndian: address.sin_port) + + let client = Process() + client.executableURL = URL(fileURLWithPath: Self.openssl) + client.arguments = ["s_client", "-dtls1_2", "-connect", "127.0.0.1:\(port)", + "-cipher", "ECDHE-ECDSA-AES128-GCM-SHA256", "-use_srtp", "SRTP_AES128_CM_SHA1_80", + "-cert", certPath, "-key", keyPath, + "-keymatexport", "EXTRACTOR-dtls_srtp", "-keymatexportlen", "60"] + let stdin = Pipe() + let stdout = Pipe() + client.standardInput = stdin + client.standardOutput = stdout + client.standardError = stdout + try client.run() + defer { client.terminate() } + + let output = OutputCollector(stdout.fileHandleForReading) + var buffer = [UInt8](repeating: 0, count: 2048) + let deadline = Date().addingTimeInterval(fingerprintMatches ? 20 : 3) + while Date() < deadline, !output.text.contains("Keying material") { + if case .failed = server.state { break } + var peer = sockaddr_storage() + var peerLength = socklen_t(MemoryLayout.size) + let n = withUnsafeMutablePointer(to: &peer) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { recvfrom(socket, &buffer, buffer.count, 0, $0, &peerLength) } + } + guard n > 0 else { continue } + for reply in server.handle(Array(buffer[0.. Data { + let process = Process() + process.executableURL = URL(fileURLWithPath: arguments[0]) + process.arguments = Array(arguments.dropFirst()) + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + try process.run() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + return data + } +} + +private final class OutputCollector: @unchecked Sendable { + private let lock = NSLock() + private var data = Data() + + init(_ handle: FileHandle) { + handle.readabilityHandler = { [weak self] h in + let chunk = h.availableData + self?.lock.withLock { self?.data.append(chunk) } + } + } + + var text: String { lock.withLock { String(decoding: data, as: UTF8.self) } } +}