import Foundation import Security import CryptoKit // One session per pinned PC. Never forward credentials to a redirected endpoint. final class PinnedDelegate: NSObject, URLSessionDelegate, URLSessionTaskDelegate { let connection: Connection init(_ connection: Connection) { self.connection = connection } func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { #if DEBUG print("MusicBridge TLS: received server authentication challenge") #endif guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, challenge.protectionSpace.host == connection.endpoint.host, challenge.protectionSpace.port == (connection.endpoint.port ?? 443), let trust = challenge.protectionSpace.serverTrust, let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate], let leaf = chain.first else { completionHandler(.cancelAuthenticationChallenge, nil); return } let digest = SHA256.hash(data: SecCertificateCopyData(leaf) as Data) .map { String(format: "%02X", $0) }.joined() guard digest == connection.fingerprint else { #if DEBUG print("MusicBridge TLS: fingerprint mismatch") #endif completionHandler(.cancelAuthenticationChallenge, nil); return } // Trust only this exact out-of-band certificate, while checking its validity dates. SecTrustSetAnchorCertificates(trust, [leaf] as CFArray) SecTrustSetAnchorCertificatesOnly(trust, true) SecTrustSetPolicies(trust, SecPolicyCreateBasicX509()) var trustError: CFError? guard SecTrustEvaluateWithError(trust, &trustError) else { #if DEBUG print("MusicBridge TLS: pinned certificate rejected: \(String(describing: trustError))") #endif completionHandler(.cancelAuthenticationChallenge, nil); return } completionHandler(.useCredential, URLCredential(trust: trust)) } func urlSession(_ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { urlSession(session, didReceive: challenge, completionHandler: completionHandler) } func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) { completionHandler(nil) } } final class BridgeClient { let connection: Connection private let session: URLSession private var socket: URLSessionWebSocketTask? init(_ connection: Connection) { self.connection = connection let config = URLSessionConfiguration.ephemeral config.timeoutIntervalForRequest = 10 config.timeoutIntervalForResource = 15 config.urlCache = nil config.httpCookieStorage = nil session = URLSession(configuration: config, delegate: PinnedDelegate(connection), delegateQueue: nil) } func close() { socket?.cancel(with: .goingAway, reason: nil); session.invalidateAndCancel() } private func request(_ path: String, body: Data? = nil, authenticated: Bool = true) -> URLRequest { var request = URLRequest(url: connection.endpoint.appendingPathComponent(path)) if authenticated, let token = connection.token { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } if let body { request.httpMethod = "POST" request.httpBody = body request.setValue("application/json", forHTTPHeaderField: "Content-Type") } return request } private func data(_ request: URLRequest, limit: Int = 256 * 1024) async throws -> Data { let (bytes, response) = try await session.bytes(for: request) guard let response = response as? HTTPURLResponse else { throw BridgeError.message("Нет ответа от ПК.") } if response.statusCode == 401 { throw BridgeError.unauthorized } let success = (200...299).contains(response.statusCode) let bodyLimit = success ? limit : min(limit, 16 * 1024) var result = Data() for try await byte in bytes { guard result.count < bodyLimit else { throw BridgeError.message("Ответ ПК слишком большой.") } result.append(byte) } guard success else { struct Failure: Decodable { let code: String? } let code = (try? JSONDecoder().decode(Failure.self, from: result))?.code throw BridgeError.http(status: response.statusCode, code: code) } return result } func pair(code: String) async throws -> String { let body = try JSONSerialization.data(withJSONObject: ["code": code, "deviceName": "MusicBridge iPhone"]) let response = try JSONDecoder().decode(PairResponse.self, from: await data(request("v1/pair", body: body, authenticated: false))) guard response.success, let token = response.token, Connection.isFingerprint(token) else { throw BridgeError.http(status: 403, code: response.code) } return token } func state() async throws -> MediaState { try Self.validate(JSONDecoder().decode(MediaState.self, from: await data(request("v1/state")))) } static func validate(_ state: MediaState) throws -> MediaState { guard state.protocolVersion == 1 else { throw BridgeError.message("Версия протокола ПК не поддерживается.") } return state } func command(_ command: Command) async throws { let response = try JSONDecoder().decode(CommandResponse.self, from: await data(request("v1/command", body: JSONEncoder().encode(command)))) guard response.success else { throw BridgeError.message(response.message) } } func artwork(_ id: String) async throws -> Data { guard Connection.isFingerprint(id) else { throw BridgeError.message("Неверный ID обложки.") } return try await data(request("v1/artwork/\(id)"), limit: 2 * 1024 * 1024) } func openEvents() { var req = request("v1/events") var url = URLComponents(url: req.url!, resolvingAgainstBaseURL: false)! url.scheme = "wss" req.url = url.url! let socket = session.webSocketTask(with: req) socket.maximumMessageSize = 256 * 1024 self.socket = socket socket.resume() } func nextState() async throws -> MediaState { guard let socket else { throw BridgeError.message("Соединение закрыто.") } let message = try await socket.receive() let payload: Data switch message { case .data(let data): payload = data case .string(let string): payload = Data(string.utf8) @unknown default: throw BridgeError.message("Неизвестный формат сообщения.") } let event = try JSONDecoder().decode(StateEvent.self, from: payload) guard event.type == "state" else { throw BridgeError.message("Неизвестное событие.") } return try Self.validate(event.state) } }