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 <noreply@anthropic.com>
main
Samuel Zielke 2 days ago
parent 0ff5e2f907
commit 3e245b1ae3

@ -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..<fragmentLength { entry.body[offset + i] = fragment[i] }
fragments[messageSeq] = entry
while let next = fragments[nextReceiveMessageSeq], !next.body.contains(where: { $0 == nil }) {
fragments[nextReceiveMessageSeq] = nil
let seq = nextReceiveMessageSeq
nextReceiveMessageSeq += 1
output += try handleMessage(next.type, next.body.map { $0! }, messageSeq: seq, epoch: epoch)
}
}
return output
}
private func handleMessage(_ type: UInt8, _ body: [UInt8], messageSeq: UInt16, epoch: UInt16) throws -> [[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..<fragment.count - 16])
let tag = Array(fragment[(fragment.count - 16)...])
guard let nonce = try? AES.GCM.Nonce(data: readCipher.iv + explicit),
let box = try? AES.GCM.SealedBox(nonce: nonce, ciphertext: ciphertext, tag: tag),
let plain = try? AES.GCM.open(box, using: readCipher.key,
authenticating: additionalData(type: type, epoch: epoch, sequence: sequence, length: ciphertext.count))
else { return nil }
return Array(plain)
}
}

@ -0,0 +1,114 @@
import CryptoKit
import Darwin
import Foundation
import Testing
@testable import Intercom
/// Handshake against `openssl s_client` (development tool only, not shipped).
/// Skipped when Homebrew OpenSSL is missing.
private let opensslPath = "/opt/homebrew/bin/openssl"
@Suite(.enabled(if: FileManager.default.isExecutableFile(atPath: opensslPath)))
struct DTLSInteropTests {
static let openssl = opensslPath
@Test(.timeLimit(.minutes(1)), arguments: [true, false])
func handshakeChecksFingerprintAndExportsSameSRTPKeys(fingerprintMatches: Bool) throws {
let dir = FileManager.default.temporaryDirectory.appendingPathComponent("musubi-dtls-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }
let keyPath = dir.appendingPathComponent("client.key").path
let certPath = dir.appendingPathComponent("client.pem").path
try run([Self.openssl, "req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:P-256", "-nodes",
"-keyout", keyPath, "-out", certPath, "-subj", "/CN=browser", "-days", "1"])
let der = try run([Self.openssl, "x509", "-in", certPath, "-outform", "DER"])
let expected = fingerprintMatches ? DTLSCertificate.fingerprint(Array(der)) : try DTLSCertificate().fingerprint
let server = DTLSServer(certificate: try DTLSCertificate(), expectedFingerprint: expected)
let socket = Darwin.socket(AF_INET, SOCK_DGRAM, 0)
defer { close(socket) }
var address = sockaddr_in()
address.sin_family = sa_family_t(AF_INET)
address.sin_addr.s_addr = inet_addr("127.0.0.1")
var length = socklen_t(MemoryLayout<sockaddr_in>.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<timeval>.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<sockaddr_storage>.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..<n])) {
_ = withUnsafePointer(to: &peer) {
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) { sendto(socket, reply, reply.count, 0, $0, peerLength) }
}
}
}
guard fingerprintMatches else {
#expect(server.state == .failed("Client certificate does not match the SDP fingerprint"))
#expect(server.keyMaterial == nil)
return
}
#expect(server.state == .established)
let text = output.text
let line = try #require(text.split(separator: "\n").first { $0.contains("Keying material:") })
let exported = String(line.split(separator: ":").last!).trimmingCharacters(in: .whitespaces).lowercased()
let keys = try #require(server.keyMaterial)
#expect(exported == (keys.clientKey + keys.serverKey + keys.clientSalt + keys.serverSalt).hex)
#expect(text.contains("SRTP Extension negotiated, profile=SRTP_AES128_CM_SHA1_80"))
}
@discardableResult
private func run(_ arguments: [String]) throws -> 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) } }
}
Loading…
Cancel
Save

Powered by TurnKey Linux.