157 lines
7.5 KiB
Swift
157 lines
7.5 KiB
Swift
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, timeout: TimeInterval = 10) {
|
|
self.connection = connection
|
|
let config = URLSessionConfiguration.ephemeral
|
|
config.timeoutIntervalForRequest = timeout
|
|
config.timeoutIntervalForResource = timeout + 5
|
|
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)
|
|
}
|
|
}
|