You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
115 lines
5.5 KiB
115 lines
5.5 KiB
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) } }
|
|
}
|